bona
Version:
Super lightweight microframework focused on static websites and landing pages.
931 lines (780 loc) • 24.4 kB
JavaScript
/**
* @typedef {Object} BonaEventOptions
* @property {boolean} [once] Trigger callback once.
*/
class Base {
constructor() {
this._events = {};
}
/**
* Attach an event handler function.
*
* @param {string} event Event name.
* @param {function} callback Callback.
* @param {BonaEventOptions} [options] Options.
*/
on(event, callback, options) {
if (options === void 0) {
options = {};
}
if (!this._events[event]) this._events[event] = [];
this._events[event].push({
callback,
options
});
}
/**
* Remove an event handler.
*
* @param {string} event Event name.
* @param {function} [callback] Callback.
*/
off(event, callback) {
if (callback) {
this._events[event] = this._events[event].filter(e => e.callback !== callback);
} else {
this._events[event] = [];
}
}
/**
* Execute all handlers for the given event type.
*
* @param {string} event Event name.
* @param params Extra parameters.
*/
trigger(event) {
if (!this._events[event]) return;
this._events[event].forEach(e => {
e.callback.call(this, ...[].slice.call(arguments, 1));
if (e.options.once) this.off(event, e.callback);
});
}
}
function _extends() {
_extends = Object.assign ? Object.assign.bind() : function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
class Core extends Base {
/**
* @typedef {Object} BonaOptions
* @property {boolean} [init] Initialize an app immediately.
* @property {Object[]} [define] Array of components to define.
*/
/**
* Initializes the core.
*
* @param {BonaOptions} [options] Options.
*/
constructor(options) {
super();
/** @type {BonaOptions} **/
this.options = _extends({
init: true,
define: null
}, options);
this.store = new Map();
this.registry = new Map();
this.isFullyLoaded = false;
if (this.options.define) this.defineAll(this.options.define);
if (this.options.init) this.init();
}
/**
* Performs basic hooks.
*
* @return {Promise}
*/
init() {
try {
const _this = this;
return Promise.resolve(_this.waitFullLoad()).then(function () {
return Promise.resolve(_this.executeAll('init')).then(function () {
return Promise.resolve(_this.executeAll('enter')).then(function () {
return Promise.resolve(_this.executeAll('complete')).then(function () {});
});
});
});
} catch (e) {
return Promise.reject(e);
}
}
/**
* Refresh component store.
*
* @param {boolean} [fireRefresh] Fire refresh hook in instances.
* @param {boolean} [fireDestroy] Fire destroy hook when detach non-existent nodes.
* @param {boolean} [detachNodes] Detach non-existent nodes from store.
* @return {Promise}
*/
refresh(fireRefresh, fireDestroy, detachNodes) {
if (fireRefresh === void 0) {
fireRefresh = true;
}
if (fireDestroy === void 0) {
fireDestroy = true;
}
if (detachNodes === void 0) {
detachNodes = true;
}
try {
const _this2 = this;
const preExecutors = [];
const executors = [];
_this2.registry.forEach((options, namespace) => {
const instances = _this2.store.get(namespace) || [];
const els = new Set(options.assign ? _this2.queryAll(options.assign) : []);
instances.forEach(instance => {
if (options.assign) {
var _instance$el;
if ((_instance$el = instance.el) != null && _instance$el.isConnected) {
if (fireRefresh) executors.push(_this2.executeInstance(instance, 'refresh'));
els.delete(instance.el);
} else {
if (detachNodes) preExecutors.push(_this2.detach(instance, fireDestroy));
}
} else {
if (fireRefresh) executors.push(_this2.executeInstance(instance, 'refresh'));
}
});
els.forEach(el => {
executors.push(_this2.attach(namespace, el));
});
});
return Promise.resolve(Promise.all(preExecutors)).then(function () {
if (fireRefresh) _this2.trigger('refresh');
return Promise.all(executors);
});
} catch (e) {
return Promise.reject(e);
}
}
/**
* Execute hooks in all instances.
*
* @param {string} [hook] Hook name.
* @return {Promise}
*/
executeAll(hook) {
if (hook === void 0) {
hook = 'init';
}
try {
const _this3 = this;
const executors = [];
_this3.trigger(hook);
_this3.store.forEach(instances => {
instances.forEach(instance => executors.push(_this3.executeInstance(instance, hook)));
});
return Promise.all(executors);
} catch (e) {
return Promise.reject(e);
}
}
/**
* Execute hook in an instance.
*
* @param {Component} instance Component instance.
* @param {string} [hook] Hook name.
* @return {Promise}
*/
executeInstance(instance, hook) {
if (hook === void 0) {
hook = 'init';
}
try {
const method = 'on' + hook.charAt(0).toUpperCase() + hook.slice(1);
if (instance[method]) return Promise.resolve(instance._executors[hook] = instance[method]());
return Promise.resolve();
} catch (e) {
return Promise.reject(e);
}
}
/**
* Wait until the component hook is completed.
*
* @param {string} namespace Namespace.
* @param {string} [hook] Hook name.
* @param {number} [index] Instance index.
* @return {Promise}
*/
wait(namespace, hook, index) {
if (hook === void 0) {
hook = 'init';
}
if (index === void 0) {
index = 0;
}
try {
const _this4 = this;
return Promise.resolve(_this4.waitInstance(_this4.get(namespace, index), hook));
} catch (e) {
return Promise.reject(e);
}
}
/**
* Wait until hooks are completed in all instances.
*
* @param {string} namespace Namespace.
* @param {string} [hook] Hook name.
* @return {Promise}
*/
waitAll(namespace, hook) {
if (hook === void 0) {
hook = 'init';
}
try {
const _this5 = this;
const executors = [];
_this5.store.get(namespace).forEach(instance => {
executors.push(_this5.waitInstance(instance, hook));
});
return Promise.all(executors);
} catch (e) {
return Promise.reject(e);
}
}
/**
* Wait until the hook is completed in an instance.
*
* @param {Component} instance Component instance.
* @param {string} [hook] Hook name.
* @return {Promise}
*/
waitInstance(instance, hook) {
if (hook === void 0) {
hook = 'init';
}
try {
return Promise.resolve(instance._executors[hook]);
} catch (e) {
return Promise.reject(e);
}
}
/**
* Wait until the page has fully loaded.
*
* @return {Promise}
*/
waitFullLoad() {
try {
const _this6 = this;
return Promise.resolve(new Promise(resolve => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', () => resolve());
}
})).then(function () {
_this6.isFullyLoaded = true;
});
} catch (e) {
return Promise.reject(e);
}
}
/**
* Create component instance in the store.
*
* @param {string} namespace Namespace.
* @param {HTMLElement} [el] Element.
* @param {Object} [options] Options that will pass to instance.
* @param {boolean} [fireInit] Fire init hook.
* @return {Component} Component instance.
*/
attach(namespace, el, options, fireInit) {
if (fireInit === void 0) {
fireInit = true;
}
try {
const _this7 = this;
const factory = _this7.registry.get(namespace).component;
const instance = new factory(_this7, el, _extends({}, _this7.registry.get(namespace).options, options));
if (!_this7.store.has(namespace)) _this7.store.set(namespace, new Set());
_this7.store.get(namespace).add(instance);
instance._namespace = namespace;
const _temp = function () {
if (fireInit) return Promise.resolve(_this7.executeInstance(instance, 'init')).then(function () {});
}();
return Promise.resolve(_temp && _temp.then ? _temp.then(function () {
return instance;
}) : instance);
} catch (e) {
return Promise.reject(e);
}
}
/**
* Remove component instance from store.
*
* @param {Component} instance Component instance.
* @param {boolean} [fireDestroy] Fire destroy hook.
* @return {Component} Component instance.
*/
detach(instance, fireDestroy) {
if (fireDestroy === void 0) {
fireDestroy = true;
}
try {
const _this8 = this;
_this8.store.get(instance._namespace).delete(instance);
const _temp2 = function () {
if (fireDestroy) return Promise.resolve(_this8.executeInstance(instance, 'destroy')).then(function () {});
}();
return Promise.resolve(_temp2 && _temp2.then ? _temp2.then(function () {
return instance;
}) : instance);
} catch (e) {
return Promise.reject(e);
}
}
/**
* Register component.
*
* @param {string} namespace Namespace.
* @param {Object} component Component class.
* @param {string} [assign] Assigned DOM selector.
* @param {Object} [options] The options object that will be passed to each instance.
*/
define(namespace, component, assign, options) {
this.registry.set(namespace, {
assign,
component,
options
});
if (assign) {
const els = this.queryAll(assign);
els.forEach(el => {
this.attach(namespace, el, null, this.isFullyLoaded);
});
} else {
this.attach(namespace, null, null, this.isFullyLoaded);
}
}
/**
* Register array of components.
*/
defineAll(arr) {
arr.forEach(item => {
this.define(item.namespace, item.component, item.assign, item.options);
});
}
/**
* Return component instance.
*
* @param {string} namespace Namespace.
* @param {number} [index] Instance index.
* @return {Component|*} Component instance.
*/
get(namespace, index) {
if (index === void 0) {
index = 0;
}
const instances = this.store.get(namespace);
return instances ? [...instances][index] : null;
}
/**
* Return all component instances.
*
* @param {string} namespace Namespace.
* @return {Component[]|*[]} Array of component instances.
*/
getAll(namespace) {
return this.store.get(namespace);
}
/**
* Find component instance that matches a CSS selector.
*
* @param {string|Element} selector Selector or node.
* @param {string} [namespace] Namespace.
* @param {number} [index] Instance index.
* @return {Component|*} Component instance.
*/
find(selector, namespace, index) {
if (index === void 0) {
index = 0;
}
const instance = this.findAll(selector, namespace);
return instance ? instance[index] : null;
}
/**
* Find all component instances that match a CSS selector.
*
* @param {string|Element} selector Selector or node.
* @param {string} [namespace] Namespace.
* @return {Component[]|*[]} Array of component instances.
*/
findAll(selector, namespace) {
const store = namespace ? [this.store.get(namespace) || []] : this.store;
const result = [];
store.forEach(instances => {
instances.forEach(instance => {
if (!instance.el) return;
if (typeof selector === 'string' ? instance.el.matches(selector) : instance.el === selector) {
result.push(instance);
}
});
});
return result;
}
/**
* Returns the first element that matches a CSS selector.
*
* @param {string|Object} selector Selector.
* @return {null|Element|Object}
*/
query(selector) {
if (typeof selector === 'string') return document.querySelector(selector);
if (typeof selector === 'object') return selector;
return null;
}
/**
* Returns all elements that match a CSS selector.
*
* @param {string|Object} selector Selector.
* @return {Element[]|Object}
*/
queryAll(selector) {
if (typeof selector === 'string') return Array.from(document.querySelectorAll(selector));
if (typeof selector === 'object') return selector;
return [];
}
}
class Component extends Base {
/**
* @param {Core} app Application instance.
* @param {HTMLElement} el Element.
* @param {object} options Options passed to instance.
*/
constructor(app, el, options) {
super();
this.app = app;
this.el = el;
this.options = options;
this._namespace = null;
this._executors = {};
}
onInit() {
return Promise.resolve();
}
onRefresh() {
return Promise.resolve();
}
onEnter() {
return Promise.resolve();
}
onComplete() {
return Promise.resolve();
}
onLeave() {
return Promise.resolve();
}
onDestroy() {
return Promise.resolve();
}
onLoaded() {
return Promise.resolve();
}
}
/**
* Replace nodes from a given document by selector.
*
* @param {string} selector Selector.
* @param {Document} doc Source document.
* @param {boolean} [removeNodes] Remove non-existent nodes.
* @param {boolean} [extendNodes] Extend or replace new nodes.
*/
const replaceNodesBySelector = function replaceNodesBySelector(selector, doc, removeNodes, extendNodes) {
if (removeNodes === void 0) {
removeNodes = true;
}
if (extendNodes === void 0) {
extendNodes = false;
}
const nodes = document.querySelectorAll(selector);
const newNodes = doc.querySelectorAll(selector);
nodes.forEach((el, index) => {
if (newNodes[index]) {
if (extendNodes) {
el.append(...newNodes[index].childNodes);
} else {
// DOMParser.parseFromString fails <source> in <picture> and <video> in Safari/FF, so wrap it in div
// https://stackoverflow.com/questions/58240755/
// el.replaceWith(newNodes[index]);
const temp = document.createElement('div');
temp.innerHTML = newNodes[index].outerHTML;
el.replaceWith(temp.firstElementChild);
}
} else if (removeNodes) {
el.remove();
}
});
};
/**
* Replace nodes from a given document by selectors.
*
* @param {string[]} selectors Selectors.
* @param {Document} doc Source document.
* @param {boolean} [removeNodes] Remove non-existent nodes.
* @param {boolean} [extendNodes] Extend or replace new nodes.
*/
const replaceNodesBySelectors = function replaceNodesBySelectors(selectors, doc, removeNodes, extendNodes) {
if (removeNodes === void 0) {
removeNodes = true;
}
if (extendNodes === void 0) {
extendNodes = false;
}
selectors.forEach(selector => {
replaceNodesBySelector(selector, doc, removeNodes, extendNodes);
});
};
/**
* Check an anchor for eligibility.
*
* @param {HTMLAnchorElement} el Anchor element.
* @param {RegExp} [checkUrlRegExp] Check the URL using RegExp.
* @param {string} [host] Current host.
* @return {boolean}
*/
const checkAnchorEligibility = function checkAnchorEligibility(el, checkUrlRegExp, host) {
if (host === void 0) {
host = window.location.host;
}
if (el.tagName !== 'A' || !el.href || el.host !== host) return false;
if (el.getAttribute('target') || el.hasAttribute('download')) return false;
if (checkUrlRegExp) {
const match = el.href.match(checkUrlRegExp);
if (!match || match[1]) return false;
}
return true;
};
/**
* @typedef {Object} BonaAjaxProps
* @property {boolean} [bindLinks] Handle all links on the page.
* @property {boolean} [bindHistory] Handle history changes.
* @property {RegExp} [checkLinkUrlRegExp] RegExp to check link URL eligibility.
* @property {boolean} [restoreScrollHistory] Restore the window scroll after the history change.
* @property {string} [scrollRestoration] Scroll restoration behavior on history navigation.
*
* @typedef {Object} BonaReqOptions
* @property {boolean} [checkResponseStatus] Check the response status and redirect if an error occurs.
* @property {string|boolean} [history] Push or replace history.
* @property {object} [historyState] Pass history state object.
* @property {boolean} [preventSame] Prevent goTo to the same URLs.
* @property {boolean} [preventHash] Prevent goTo to hashes.
* @property {boolean} [preventRunning] Prevent goTo when a transition is already running.
* @property {DOMParserSupportedType} [parserType] Specifies the parser type used to parse the DOM.
* @property {string[]} [updateSelectors] Selectors to update.
* @property {boolean} [extendNodes] Extend or replace new nodes.
* @property {boolean} [removeNodes] Remove non-existent nodes.
* @property {boolean} [detachNodes] Detach non-existent nodes from store.
* @property {boolean} [resetScroll] Reset the window scroll after an update.
* @property {boolean} [restoreScroll] Restore the window scroll after an update.
* @property {boolean} [scrollToHash] Scroll to hashes after an update.
* @property {boolean} [fireLeave] Fire leave hook.
* @property {boolean} [fireLoaded] Fire loaded hook.
* @property {boolean} [fireRefresh] Fire refresh ho.ok.
* @property {boolean} [fireEnter] Fire enter hook.
* @property {boolean} [fireComplete] Fire complete hook.
* @property {boolean} [fireDestroy] Fire destroy hook.
* @property {object} [fetch] Fetch options.
*
* @typedef {BonaAjaxProps & BonaReqOptions} BonaAjaxOptions
*/
class Ajax extends Component {
constructor() {
super(...arguments);
/** @type {BonaAjaxOptions} **/
this.options = _extends({
bindLinks: true,
bindHistory: true,
checkLinkUrlRegExp: /(\?.*)?\/(?:|[^.]+(?:\.(?:htm|html|php)|))(?:\?.*|)$/,
// regex101.com/r/k4iNu1/1
checkResponseStatus: true,
history: 'push',
historyState: {},
preventSame: false,
preventHash: true,
preventRunning: false,
parserType: 'text/html',
scrollRestoration: 'manual',
updateSelectors: ['title', 'meta', '#view-main'],
extendNodes: false,
removeNodes: true,
detachNodes: true,
resetScroll: true,
restoreScroll: false,
restoreScrollHistory: true,
scrollToHash: true,
fireLeave: true,
fireLoaded: true,
fireRefresh: true,
fireEnter: true,
fireComplete: true,
fireDestroy: true,
fetch: {}
}, this.options);
this.event = {};
this.scroll = {};
this.parser = new DOMParser();
this.running = false;
this.url = new URL(window.location.href);
this.prevUrl = null;
if (this.options.scrollRestoration) window.history.scrollRestoration = this.options.scrollRestoration;
if (this.options.bindLinks) this.bindLinks();
if (this.options.bindHistory) this.bindHistory();
}
/**
* Handle all links on the page.
*/
bindLinks() {
document.addEventListener('click', e => {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
for (let target = e.target; target && target !== document; target = target.parentNode) {
if (!checkAnchorEligibility(target, this.options.checkLinkUrlRegExp)) continue;
e.preventDefault();
this.goTo(target.href);
break;
}
});
}
/**
* Handle history change.
*/
bindHistory() {
this.event.popstate = () => {
this.goTo(window.location.href, {
history: false,
preventRunning: false,
restoreScroll: this.options.restoreScrollHistory
});
};
window.addEventListener('popstate', this.event.popstate);
}
/**
* Push or replace url in history.
*
* @param {string} url Url.
* @param {string} [method] Push or replace.
* @param {object} [state] History state object.
*/
pushHistory(url, method, state) {
if (method === void 0) {
method = 'push';
}
if (state === void 0) {
state = {};
}
window.history[method === 'push' ? 'pushState' : 'replaceState'](state, '', url);
}
/**
* Go to a new location.
*
* @param {string} url URL.
* @param {BonaReqOptions} [options] Request options.
* @return {Promise}
*/
goTo(url, options) {
try {
const _this = this;
options = _extends({}, _this.options, options);
if (options.preventRunning && _this.running) {
return Promise.resolve(false);
}
_this.prevUrl = _this.url;
_this.url = new URL(url, window.location.origin);
if (options.preventSame && _this.url.href === _this.prevUrl.href) {
return Promise.resolve(false);
}
if (options.preventHash && _this.url.hash !== _this.prevUrl.hash) {
const cleanUrl = _this.url.href.split('#')[0];
const cleanPrevUrl = _this.prevUrl.href.split('#')[0];
if (cleanUrl === cleanPrevUrl) {
return Promise.resolve(false);
}
}
_this.scroll[_this.prevUrl.href] = {
top: window.scrollY,
left: window.scrollX
};
_this.running = true;
if (options.history) _this.pushHistory(_this.url.href, options.history, options.historyState);
return Promise.resolve(Promise.all([_this.executeRequest(url, options), options.fireLeave ? _this.app.executeAll('leave') : null])).then(function (data) {
function _temp6() {
if (options.updateSelectors) {
replaceNodesBySelectors(options.updateSelectors, data[0], options.removeNodes, options.extendNodes);
}
if (options.resetScroll) {
window.scrollTo(0, 0);
}
return Promise.resolve(_this.app.refresh(options.fireRefresh, options.fireDestroy, options.detachNodes)).then(function () {
function _temp4() {
function _temp2() {
_this.running = false;
}
const _temp = function () {
if (options.fireComplete) return Promise.resolve(_this.app.executeAll('complete')).then(function () {});
}();
return _temp && _temp.then ? _temp.then(_temp2) : _temp2(_temp);
}
if (options.restoreScroll && _this.scroll[_this.url.href]) {
window.scrollTo(_this.scroll[_this.url.href]);
} else if (options.scrollToHash && _this.url.hash && _this.app.query(_this.url.hash)) {
var _this$app$query;
(_this$app$query = _this.app.query(_this.url.hash)) == null ? void 0 : _this$app$query.scrollIntoView();
}
const _temp3 = function () {
if (options.fireEnter) return Promise.resolve(_this.app.executeAll('enter')).then(function () {});
}();
return _temp3 && _temp3.then ? _temp3.then(_temp4) : _temp4(_temp3);
});
}
const _temp5 = function () {
if (options.fireLoaded) return Promise.resolve(_this.app.executeAll('loaded')).then(function () {});
}();
return _temp5 && _temp5.then ? _temp5.then(_temp6) : _temp6(_temp5);
});
} catch (e) {
return Promise.reject(e);
}
}
/**
* Execute prepared request.
*
* @param {string} url Url.
* @param {BonaReqOptions} [options] Request options.
* @return {Promise}
*/
executeRequest(url, options) {
try {
const _this2 = this;
return Promise.resolve(fetch(url, options.fetch)).then(function (req) {
if (options.checkResponseStatus && !req.ok) {
window.location.assign(url);
return;
}
return Promise.resolve(req.text()).then(function (data) {
return _this2.parser.parseFromString(data, options.parserType);
});
});
} catch (e) {
return Promise.reject(e);
}
}
}
/*!
* Bona
* https://cuberto.com/
*
* @version 1.1.0
* @author Cuberto, Artem Dordzhiev (Draft)
*/
const Bona = new Core();
export { Ajax, Base, Bona, Component, Core, Core as default };