feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run

- Base: wintercms/winter branch 1.2 (full framework)
- Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS
- Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication
- Partials: hero (offline-first), features, modes (offline/nube toggle),
  screenshots, pricing (3 planes), comparison, FAQ, CTA
- Plugin VivesPOS.Site with ContactForm
- Dockerfile: PHP 8.2 Apache, port 80, healthcheck
- Added winter/wn-pages, blog, sitemap, seo plugins
- Active theme set to vivespos
This commit is contained in:
2026-08-21 19:29:00 -06:00
commit 1f72193a64
3266 changed files with 531480 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
/**
* Plugin base abstract.
*
* This class provides the base functionality for all plugins.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class PluginBase {
/**
* Constructor.
*
* The constructor is provided the Snowboard framework instance, and should not be overwritten
* unless you absolutely know what you're doing.
*
* @param {Snowboard} snowboard
*/
constructor(snowboard) {
this.snowboard = snowboard;
}
/**
* Plugin constructor.
*
* This method should be treated as the true constructor of a plugin, and can be overwritten.
* It will be called straight after construction.
*/
construct() {
}
/**
* Defines the required plugins for this specific module to work.
*
* @returns {string[]} An array of plugins required for this module to work, as strings.
*/
dependencies() {
return [];
}
/**
* Defines the listener methods for global events.
*
* @returns {Object}
*/
listens() {
return {};
}
/**
* Plugin destructor.
*
* Fired when this plugin is removed. Can be manually called if you have another scenario for
* destruction, ie. the element attached to the plugin is removed or changed.
*/
destruct() {
this.detach();
delete this.snowboard;
}
/**
* Plugin destructor (old method name).
*
* Allows previous usage of the "destructor" method to still work.
*/
destructor() {
this.destruct();
}
}

View File

@@ -0,0 +1,15 @@
import PluginBase from './PluginBase';
/**
* Singleton plugin abstract.
*
* This is a special definition class that the Snowboard framework will use to interpret the current plugin as a
* "singleton". This will ensure that only one instance of the plugin class is used across the board.
*
* Singletons are initialised on the "domReady" event by default.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Singleton extends PluginBase {
}

View File

@@ -0,0 +1,889 @@
import PluginBase from '../abstracts/PluginBase';
/**
* Request plugin.
*
* This is the default AJAX handler which will run using the `fetch()` method that is default in modern browsers.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Request extends PluginBase {
/**
* Constructor.
*
* The constructor accepts 2 or 3 parameters.
*
* If 2 parameters are provided, the first parameter is the handler name and the second
* parameter is the options. This assumes that this is a detached AJAX request not connected to
* an element.
*
* If 3 parameters are provided, the first parameter is an element or a selector, and the second
* and third parameters are the handler and options, respectively.
*
* @param {HTMLElement|string} element
* @param {string|Object} handler
* @param {Object} options
*/
construct(element, handler, options) {
if (typeof element === 'string') {
// Allow the element to be a handler name.
// This assumes the request is being made against no element, and the handler parameter
// will contain options.
if (this.isHandlerName(element)) {
this.element = null;
this.handler = element;
this.options = handler || {};
} else {
const matchedElement = document.querySelector(element);
if (matchedElement === null) {
throw new Error(`No element was found with the given selector: ${element}`);
}
this.element = matchedElement;
this.handler = handler;
this.options = options || {};
}
} else {
this.element = element;
this.handler = handler;
this.options = options || {};
}
this.fetchOptions = {};
this.responseData = null;
this.responseError = null;
this.cancelled = false;
this.checkRequest();
if (!this.snowboard.globalEvent('ajaxSetup', this)) {
this.cancelled = true;
return;
}
if (this.element) {
const event = new Event('ajaxSetup', { cancelable: true });
event.request = this;
this.element.dispatchEvent(event);
if (event.defaultPrevented) {
this.cancelled = true;
return;
}
}
if (!this.doClientValidation()) {
this.cancelled = true;
return;
}
if (this.confirm) {
this.doConfirm().then((confirmed) => {
if (confirmed) {
this.doAjax().then(
(response) => {
if (response.cancelled) {
this.cancelled = true;
this.complete();
return;
}
this.responseData = response;
this.processUpdate(response).then(
() => {
if (response.X_WINTER_SUCCESS === false) {
this.processError(response);
} else {
this.processResponse(response);
}
},
);
},
(error) => {
this.responseError = error;
this.processError(error);
},
);
}
});
} else {
this.doAjax().then(
(response) => {
if (response.cancelled) {
this.cancelled = true;
this.complete();
return;
}
this.responseData = response;
this.processUpdate(response).then(
() => {
if (response.X_WINTER_SUCCESS === false) {
this.processError(response);
} else {
this.processResponse(response);
}
},
);
},
(error) => {
this.responseError = error;
this.processError(error);
},
);
}
}
/**
* Dependencies for this plugin.
*
* @returns {string[]}
*/
dependencies() {
return ['cookie', 'jsonParser'];
}
/**
* Validates the element and handler given in the request.
*/
checkRequest() {
if (this.element && this.element instanceof Element === false) {
throw new Error('The element provided must be an Element instance');
}
if (this.handler === undefined) {
throw new Error('The AJAX handler name is not specified.');
}
if (!this.isHandlerName(this.handler)) {
throw new Error('Invalid AJAX handler name. The correct handler name format is: "onEvent".');
}
}
/**
* Creates a Fetch request.
*
* This method is made available for plugins to extend or override the default fetch() settings with their own.
*
* @returns {Promise}
*/
getFetch() {
this.fetchOptions = (this.options.fetchOptions !== undefined && typeof this.options.fetchOptions === 'object')
? this.options.fetchOptions
: {
method: 'POST',
headers: this.headers,
body: this.data,
redirect: 'follow',
mode: 'same-origin',
};
this.snowboard.globalEvent('ajaxFetchOptions', this.fetchOptions, this);
return fetch(this.url, this.fetchOptions);
}
/**
* Run client-side validation on the form, if available.
*
* @returns {boolean}
*/
doClientValidation() {
if (this.options.browserValidate === true && this.form) {
if (this.form.checkValidity() === false) {
this.form.reportValidity();
return false;
}
}
return true;
}
/**
* Executes the AJAX query.
*
* Returns a Promise object for when the AJAX request is completed.
*
* @returns {Promise}
*/
doAjax() {
// Allow plugins to cancel the AJAX request before sending
if (this.snowboard.globalEvent('ajaxBeforeSend', this) === false) {
return Promise.resolve({
cancelled: true,
});
}
const ajaxPromise = new Promise((resolve, reject) => {
this.getFetch().then(
(response) => {
if (!response.ok && response.status !== 406) {
if (response.headers.has('Content-Type') && response.headers.get('Content-Type').includes('/json')) {
response.json().then(
(responseData) => {
if (responseData.message && responseData.exception) {
reject(this.renderError(
responseData.message,
responseData.exception,
responseData.file,
responseData.line,
responseData.trace,
));
} else {
reject(responseData);
}
},
(error) => {
reject(this.renderError(`Unable to parse JSON response: ${error}`));
},
);
} else {
response.text().then(
(responseText) => {
reject(this.renderError(responseText));
},
(error) => {
reject(this.renderError(`Unable to process response: ${error}`));
},
);
}
return;
}
if (response.headers.has('Content-Type') && response.headers.get('Content-Type').includes('/json')) {
response.json().then(
(responseData) => {
resolve({
...responseData,
X_WINTER_SUCCESS: response.status !== 406,
X_WINTER_RESPONSE_CODE: response.status,
});
},
(error) => {
reject(this.renderError(`Unable to parse JSON response: ${error}`));
},
);
} else {
response.text().then(
(responseData) => {
resolve(responseData);
},
(error) => {
reject(this.renderError(`Unable to process response: ${error}`));
},
);
}
},
(responseError) => {
reject(this.renderError(`Unable to retrieve a response from the server: ${responseError}`));
},
);
});
this.snowboard.globalEvent('ajaxStart', ajaxPromise, this);
if (this.element) {
const event = new Event('ajaxPromise');
event.promise = ajaxPromise;
this.element.dispatchEvent(event);
}
return ajaxPromise;
}
/**
* Prepares for updating the partials from the AJAX response.
*
* If any partials are returned from the AJAX response, this method will also action the partial updates.
*
* Returns a Promise object which tracks when the partial update is complete.
*
* @param {Object} response
* @returns {Promise}
*/
processUpdate(response) {
return new Promise((resolve, reject) => {
if (typeof this.options.beforeUpdate === 'function') {
if (this.options.beforeUpdate.apply(this, [response]) === false) {
resolve();
return;
}
}
// Extract partial information
const partials = {};
Object.entries(response).forEach((entry) => {
const [key, value] = entry;
if (key.substr(0, 8) !== 'X_WINTER') {
partials[key] = value;
}
});
if (Object.keys(partials).length === 0) {
if (response.X_WINTER_ASSETS) {
this.processAssets(response.X_WINTER_ASSETS).then(
() => {
resolve();
},
() => {
reject();
},
);
} else {
resolve();
}
return;
}
const promises = this.snowboard.globalPromiseEvent('ajaxBeforeUpdate', response, this);
promises.then(
async () => {
if (response.X_WINTER_ASSETS) {
await this.processAssets(response.X_WINTER_ASSETS);
}
this.doUpdate(partials).then(
() => {
// Allow for HTML redraw
window.requestAnimationFrame(() => resolve());
},
() => {
reject();
},
);
},
() => {
resolve();
},
);
});
}
/**
* Updates the partials with the given content.
*
* @param {Object} partials
* @returns {Promise}
*/
doUpdate(partials) {
return new Promise((resolve) => {
const affected = [];
Object.entries(partials).forEach((entry) => {
const [partial, content] = entry;
let selector = (this.options.update && this.options.update[partial])
? this.options.update[partial]
: partial;
let mode = 'replace';
if (selector.substr(0, 1) === '@') {
mode = 'append';
selector = selector.substr(1);
} else if (selector.substr(0, 1) === '^') {
mode = 'prepend';
selector = selector.substr(1);
} else if (selector.substr(0, 1) !== '#' && selector.substr(0, 1) !== '.') {
mode = 'noop';
}
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
elements.forEach((element) => {
switch (mode) {
case 'append':
element.innerHTML += content;
break;
case 'prepend':
element.innerHTML = content + element.innerHTML;
break;
case 'noop':
break;
case 'replace':
default:
element.innerHTML = content;
break;
}
affected.push(element);
// Fire update event for each element that is updated
this.snowboard.globalEvent('ajaxUpdate', element, content, this);
const event = new Event('ajaxUpdate');
event.content = content;
element.dispatchEvent(event);
});
}
});
this.snowboard.globalEvent('ajaxUpdateComplete', affected, this);
resolve();
});
}
/**
* Processes the response data.
*
* This fires off all necessary processing functions depending on the response, ie. if there's any flash
* messages to handle, or any redirects to be undertaken.
*
* @param {Object} response
* @returns {void}
*/
processResponse(response) {
if (this.options.success && typeof this.options.success === 'function') {
if (this.options.success(this.responseData, this) === false) {
return;
}
}
// Allow plugins to cancel any further response handling
if (this.snowboard.globalEvent('ajaxSuccess', this.responseData, this) === false) {
return;
}
// Allow the element to cancel any further response handling
if (this.element) {
const event = new Event('ajaxDone', { cancelable: true });
event.responseData = this.responseData;
event.request = this;
this.element.dispatchEvent(event);
if (event.defaultPrevented) {
return;
}
}
if (this.flash && response.X_WINTER_FLASH_MESSAGES) {
this.processFlashMessages(response.X_WINTER_FLASH_MESSAGES);
}
// Check for a redirect from the response, or use the redirect as specified in the options.
if (this.redirect || response.X_WINTER_REDIRECT) {
this.processRedirect(this.redirect || response.X_WINTER_REDIRECT);
return;
}
this.complete();
}
/**
* Processes an error response from the AJAX request.
*
* This fires off all necessary processing functions depending on the error response, ie. if there's any error or
* validation messages to handle.
*
* @param {Object|Error} error
*/
processError(error) {
if (this.options.error && typeof this.options.error === 'function') {
if (this.options.error(this.responseError, this) === false) {
return;
}
}
// Allow plugins to cancel any further error handling
if (this.snowboard.globalEvent('ajaxError', this.responseError, this) === false) {
return;
}
// Allow the element to cancel any further error handling
if (this.element) {
const event = new Event('ajaxFail', { cancelable: true });
event.responseError = this.responseError;
event.request = this;
this.element.dispatchEvent(event);
if (event.defaultPrevented) {
return;
}
}
if (error instanceof Error) {
this.processErrorMessage(error.message);
} else {
let skipError = false;
// Process validation errors
if (error.X_WINTER_ERROR_FIELDS) {
skipError = this.processValidationErrors(error.X_WINTER_ERROR_FIELDS);
}
if (error.X_WINTER_ERROR_MESSAGE && !skipError) {
this.processErrorMessage(error.X_WINTER_ERROR_MESSAGE);
}
}
this.complete();
}
/**
* Processes a redirect response.
*
* By default, this processor will simply redirect the user in their browser.
*
* Plugins can augment this functionality from the `ajaxRedirect` event. You may also override this functionality on
* a per-request basis through the `handleRedirectResponse` callback option. If a `false` is returned from either, the
* redirect will be cancelled.
*
* @param {string} url
* @returns {void}
*/
processRedirect(url) {
// Run a custom per-request redirect handler. If false is returned, don't run the redirect.
if (typeof this.options.handleRedirectResponse === 'function') {
if (this.options.handleRedirectResponse.apply(this, [url]) === false) {
return;
}
}
// Allow plugins to cancel the redirect
if (this.snowboard.globalEvent('ajaxRedirect', url, this) === false) {
return;
}
// Indicate that the AJAX request is finished if we're still on the current page
// so that the loading indicator for redirects that just change the hash value of
// the URL instead of leaving the page will properly stop.
// @see https://github.com/octobercms/october/issues/2780
window.addEventListener('popstate', () => {
if (this.element) {
const event = document.createEvent('CustomEvent');
event.eventName = 'ajaxRedirected';
this.element.dispatchEvent(event);
}
}, {
once: true,
});
window.location.assign(url);
}
/**
* Processes an error message.
*
* By default, this processor will simply alert the user through a simple `alert()` call.
*
* Plugins can augment this functionality from the `ajaxErrorMessage` event. You may also override this functionality
* on a per-request basis through the `handleErrorMessage` callback option. If a `false` is returned from either, the
* error message handling will be cancelled.
*
* @param {string} message
* @returns {void}
*/
processErrorMessage(message) {
// Run a custom per-request handler for error messages. If false is returned, do not process the error messages
// any further.
if (typeof this.options.handleErrorMessage === 'function') {
if (this.options.handleErrorMessage.apply(this, [message]) === false) {
return;
}
}
// Allow plugins to cancel the error message being shown
if (this.snowboard.globalEvent('ajaxErrorMessage', message, this) === false) {
return;
}
// By default, show a browser error message
window.alert(message);
}
/**
* Processes flash messages from the response.
*
* By default, no flash message handling will occur.
*
* Plugins can augment this functionality from the `ajaxFlashMessages` event. You may also override this functionality
* on a per-request basis through the `handleFlashMessages` callback option. If a `false` is returned from either, the
* flash message handling will be cancelled.
*
* @param {Object} messages
* @returns
*/
processFlashMessages(messages) {
// Run a custom per-request flash handler. If false is returned, don't show the flash message
if (typeof this.options.handleFlashMessages === 'function') {
if (this.options.handleFlashMessages.apply(this, [messages]) === false) {
return;
}
}
this.snowboard.globalEvent('ajaxFlashMessages', messages, this);
}
/**
* Processes validation errors for fields.
*
* By default, no validation error handling will occur.
*
* Plugins can augment this functionality from the `ajaxValidationErrors` event. You may also override this functionality
* on a per-request basis through the `handleValidationErrors` callback option. If a `false` is returned from either, the
* validation error handling will be cancelled.
*
* @param {Object} fields
* @returns
*/
processValidationErrors(fields) {
if (typeof this.options.handleValidationErrors === 'function') {
if (this.options.handleValidationErrors.apply(this, [this.form, fields]) === false) {
return true;
}
}
// Allow plugins to cancel the validation errors being handled
if (this.snowboard.globalEvent('ajaxValidationErrors', this.form, fields, this) === false) {
return true;
}
return false;
}
/**
* Processes assets returned by an AJAX request.
*
* By default, no asset processing will occur and this will return a resolved Promise.
*
* Plugins can augment this functionality from the `ajaxLoadAssets` event. This event is considered blocking, and
* allows assets to be loaded or processed before continuing with any additional functionality.
*
* @param {Object} assets
* @returns {Promise}
*/
processAssets(assets) {
return this.snowboard.globalPromiseEvent('ajaxLoadAssets', assets);
}
/**
* Confirms the request with the user before proceeding.
*
* This is an asynchronous method. By default, it will use the browser's `confirm()` method to query the user to
* confirm the action. This method will return a Promise with a boolean value depending on whether the user confirmed
* or not.
*
* Plugins can augment this functionality from the `ajaxConfirmMessage` event. You may also override this functionality
* on a per-request basis through the `handleConfirmMessage` callback option. If a `false` is returned from either,
* the confirmation is assumed to have been denied.
*
* @returns {Promise}
*/
async doConfirm() {
// Allow for a custom handler for the confirmation, per request.
if (typeof this.options.handleConfirmMessage === 'function') {
if (this.options.handleConfirmMessage.apply(this, [this.confirm]) === false) {
return false;
}
return true;
}
// If no plugins have customised the confirmation, use a simple browser confirmation.
if (this.snowboard.listensToEvent('ajaxConfirmMessage').length === 0) {
return window.confirm(this.confirm);
}
// Run custom plugin confirmations
const promises = this.snowboard.globalPromiseEvent('ajaxConfirmMessage', this.confirm, this);
try {
const fulfilled = await promises;
if (fulfilled) {
return true;
}
} catch (e) {
return false;
}
return false;
}
/**
* Fires off completion events for the Request.
*/
complete() {
if (this.options.complete && typeof this.options.complete === 'function') {
this.options.complete(this.responseData, this);
}
this.snowboard.globalEvent('ajaxDone', this.responseData, this);
if (this.element) {
const event = new Event('ajaxAlways');
event.request = this;
event.responseData = this.responseData;
event.responseError = this.responseError;
this.element.dispatchEvent(event);
}
// Fire off the destructor
this.destruct();
}
get form() {
if (this.options.form) {
if (typeof this.options.form === 'string') {
return document.querySelector(this.options.form);
}
return this.options.form;
}
if (!this.element) {
return null;
}
if (this.element.tagName === 'FORM') {
return this.element;
}
return this.element.closest('form');
}
get context() {
return {
handler: this.handler,
options: this.options,
};
}
get headers() {
const headers = {
'X-Requested-With': 'XMLHttpRequest', // Keeps compatibility with jQuery AJAX
'X-WINTER-REQUEST-HANDLER': this.handler,
'X-WINTER-REQUEST-PARTIALS': this.extractPartials(this.options.update || []),
};
if (this.flash) {
headers['X-WINTER-REQUEST-FLASH'] = 1;
}
if (this.xsrfToken) {
headers['X-XSRF-TOKEN'] = this.xsrfToken;
}
return headers;
}
get loading() {
return this.options.loading || false;
}
get url() {
return this.options.url || window.location.href;
}
get redirect() {
return (this.options.redirect && this.options.redirect.length) ? this.options.redirect : null;
}
get flash() {
return this.options.flash || false;
}
get files() {
if (this.options.files === true) {
if (FormData === undefined) {
this.snowboard.debug('This browser does not support file uploads');
return false;
}
return true;
}
return false;
}
get xsrfToken() {
return this.snowboard.cookie().get('XSRF-TOKEN');
}
get data() {
const data = (typeof this.options.data === 'object') ? this.options.data : {};
const formData = new FormData(this.form || undefined);
if (Object.keys(data).length > 0) {
this.createFormData(formData, data);
}
return formData;
}
/**
* Recursively adds data to a FormData object.
*
* This method is used internally to recursively add data to a FormData object, ensuring that
* objects and arrays are correctly prefixed and added as POST data.
*
* @param {FormData} formData
* @param {Object} data
* @param {string} prefix
* @returns {void}
*/
createFormData(formData, data, prefix = '') {
if (data === null || data === undefined) {
return;
}
if (typeof data !== 'object') {
formData.append(prefix, data);
return;
}
if (Array.isArray(data) && prefix !== '') {
data.forEach((item, index) => {
this.createFormData(formData, item, `${prefix}[${index}]`);
});
return;
}
Object.entries(data).forEach((entry) => {
const [key, value] = entry;
this.createFormData(
formData,
value,
(prefix !== '') ? `${prefix}[${key}]` : key,
);
});
}
get confirm() {
return this.options.confirm || false;
}
/**
* Extracts partials.
*
* @param {Object} update
* @returns {string}
*/
extractPartials(update) {
return Object.keys(update).join('&');
}
/**
* Renders an error with useful debug information.
*
* This method is used internally when the AJAX request could not be completed or processed correctly due to an error.
*
* @param {string} message
* @param {string} exception
* @param {string} file
* @param {Number} line
* @param {string[]} trace
* @returns {Error}
*/
renderError(message, exception, file, line, trace) {
const error = new Error(message);
error.exception = exception || null;
error.file = file || null;
error.line = line || null;
error.trace = trace || [];
return error;
}
/**
* Checks a given string to see if it is a valid AJAX handler name.
*
* @param {String} name
* @returns {Boolean}
*/
isHandlerName(name) {
return /^(?:\w+:{2})?on[A-Z0-9]/.test(name);
}
}

View File

@@ -0,0 +1,332 @@
import Singleton from '../../abstracts/Singleton';
/**
* Enable Data Attributes API for AJAX requests.
*
* This is an extension of the base AJAX functionality that includes handling of HTML data attributes for processing
* AJAX requests. It is separated from the base AJAX functionality to allow developers to opt-out of data attribute
* requests if they do not intend to use them.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class AttributeRequest extends Singleton {
/**
* Listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxSetup: 'onAjaxSetup',
};
}
/**
* Ready event callback.
*
* Attaches handlers to the window to listen for all request interactions.
*/
ready() {
this.attachHandlers();
this.disableDefaultFormValidation();
}
/**
* Dependencies.
*
* @returns {string[]}
*/
dependencies() {
return ['request', 'jsonParser'];
}
/**
* Destructor.
*
* Detaches all handlers.
*/
destruct() {
this.detachHandlers();
super.destruct();
}
/**
* Attaches the necessary handlers for all request interactions.
*/
attachHandlers() {
window.addEventListener('change', (event) => this.changeHandler(event));
window.addEventListener('click', (event) => this.clickHandler(event));
window.addEventListener('keydown', (event) => this.keyDownHandler(event));
window.addEventListener('submit', (event) => this.submitHandler(event));
}
/**
* Disables default form validation for AJAX forms.
*
* A form that contains a `data-request` attribute to specify an AJAX call without including a `data-browser-validate`
* attribute means that the AJAX callback function will likely be handling the validation instead.
*/
disableDefaultFormValidation() {
document.querySelectorAll('form[data-request]:not([data-browser-validate])').forEach((form) => {
form.setAttribute('novalidate', true);
});
}
/**
* Detaches the necessary handlers for all request interactions.
*/
detachHandlers() {
window.removeEventListener('change', (event) => this.changeHandler(event));
window.removeEventListener('click', (event) => this.clickHandler(event));
window.removeEventListener('keydown', (event) => this.keyDownHandler(event));
window.removeEventListener('submit', (event) => this.submitHandler(event));
}
/**
* Handles changes to select, radio, checkbox and file inputs.
*
* @param {Event} event
*/
changeHandler(event) {
// Check that we are changing a valid element
if (!event.target.matches(
'select[data-request], input[type=radio][data-request], input[type=checkbox][data-request], input[type=file][data-request]',
)) {
return;
}
this.processRequestOnElement(event.target);
}
/**
* Handles clicks on hyperlinks and buttons.
*
* This event can bubble up the hierarchy to find a suitable request element.
*
* @param {Event} event
*/
clickHandler(event) {
let currentElement = event.target;
while (currentElement && currentElement.tagName !== 'HTML') {
if (!currentElement.matches(
'a[data-request], button[data-request], input[type=button][data-request], input[type=submit][data-request]',
)) {
currentElement = currentElement.parentElement;
} else {
event.preventDefault();
this.processRequestOnElement(currentElement);
break;
}
}
}
/**
* Handles key presses on inputs
*
* @param {Event} event
*/
keyDownHandler(event) {
// Check that we are inputting into a valid element
if (!event.target.matches(
'input',
)) {
return;
}
// Check that the input type is valid
const validTypes = [
'checkbox',
'color',
'date',
'datetime',
'datetime-local',
'email',
'image',
'month',
'number',
'password',
'radio',
'range',
'search',
'tel',
'text',
'time',
'url',
'week',
];
if (validTypes.indexOf(event.target.getAttribute('type')) === -1) {
return;
}
if (event.key === 'Enter' && event.target.matches('*[data-request]')) {
this.processRequestOnElement(event.target);
event.preventDefault();
event.stopImmediatePropagation();
} else if (event.target.matches('*[data-track-input]')) {
this.trackInput(event.target);
}
}
/**
* Handles form submissions.
*
* @param {Event} event
*/
submitHandler(event) {
// Check that we are submitting a valid form
if (!event.target.matches(
'form[data-request]',
)) {
return;
}
event.preventDefault();
this.processRequestOnElement(event.target);
}
/**
* Processes a request on a given element, using its data attributes.
*
* @param {HTMLElement} element
*/
processRequestOnElement(element) {
const data = element.dataset;
const handler = String(data.request);
const options = {
confirm: ('requestConfirm' in data) ? String(data.requestConfirm) : null,
redirect: ('requestRedirect' in data) ? String(data.requestRedirect) : null,
loading: ('requestLoading' in data) ? String(data.requestLoading) : null,
stripe: ('requestStripe' in data) ? data.requestStripe === 'true' : true,
flash: ('requestFlash' in data),
files: ('requestFiles' in data),
browserValidate: ('requestBrowserValidate' in data),
form: ('requestForm' in data) ? String(data.requestForm) : null,
url: ('requestUrl' in data) ? String(data.requestUrl) : null,
update: ('requestUpdate' in data) ? this.parseData(String(data.requestUpdate)) : [],
data: ('requestData' in data) ? this.parseData(String(data.requestData)) : [],
};
this.snowboard.request(element, handler, options);
}
/**
* Sets up an AJAX request via HTML attributes.
*
* @param {Request} request
*/
onAjaxSetup(request) {
if (!request.element) {
return;
}
const fieldName = request.element.getAttribute('name');
const data = {
...this.getParentRequestData(request.element),
...request.options.data,
};
if (request.element && request.element.matches('input, textarea, select, button') && !request.form && fieldName && !request.options.data[fieldName]) {
data[fieldName] = request.element.value;
}
request.options.data = data;
}
/**
* Parses and collates all data from elements up the DOM hierarchy.
*
* @param {Element} target
* @returns {Object}
*/
getParentRequestData(target) {
const elements = [];
let data = {};
let currentElement = target;
while (currentElement.parentElement && currentElement.parentElement.tagName !== 'HTML') {
elements.push(currentElement.parentElement);
currentElement = currentElement.parentElement;
}
elements.reverse();
elements.forEach((element) => {
const elementData = element.dataset;
if ('requestData' in elementData) {
data = {
...data,
...this.parseData(elementData.requestData),
};
}
});
return data;
}
/**
* Parses data in the Winter/October JSON format.
*
* @param {String} data
* @returns {Object}
*/
parseData(data) {
let value;
if (data === undefined) {
value = '';
}
if (typeof value === 'object') {
return value;
}
try {
return this.snowboard.jsonparser().parse(`{${data}}`);
} catch (e) {
throw new Error(`Error parsing the data attribute on element: ${e.message}`);
}
}
trackInput(element) {
const { lastValue } = element.dataset;
const interval = element.dataset.trackInput || 300;
if (lastValue !== undefined && lastValue === element.value) {
return;
}
this.resetTrackInputTimer(element);
element.dataset.inputTimer = window.setTimeout(() => {
if (element.dataset.request) {
this.processRequestOnElement(element);
return;
}
// Traverse up the hierarchy and find a form that sends an AJAX query
let currentElement = element;
while (currentElement.parentElement && currentElement.parentElement.tagName !== 'HTML') {
currentElement = currentElement.parentElement;
if (currentElement.tagName === 'FORM' && currentElement.dataset.request) {
this.processRequestOnElement(currentElement);
break;
}
}
}, interval);
}
resetTrackInputTimer(element) {
if (element.dataset.inputTimer) {
window.clearTimeout(element.dataset.inputTimer);
element.dataset.inputTimer = null;
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
"use strict";(self.webpackChunk_wintercms_wn_system_module=self.webpackChunk_wintercms_wn_system_module||[]).push([[969],{478:function(e,t,n){
/*! js-cookie v3.0.5 | MIT */
function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)e[r]=n[r]}return e}n.d(t,{A:function(){return o}});var o=function e(t,n){function o(e,o,i){if("undefined"!=typeof document){"number"==typeof(i=r({},n,i)).expires&&(i.expires=new Date(Date.now()+864e5*i.expires)),i.expires&&(i.expires=i.expires.toUTCString()),e=encodeURIComponent(e).replace(/%(2[346B]|5E|60|7C)/g,decodeURIComponent).replace(/[()]/g,escape);var c="";for(var u in i)i[u]&&(c+="; "+u,!0!==i[u]&&(c+="="+i[u].split(";")[0]));return document.cookie=e+"="+t.write(o,e)+c}}return Object.create({set:o,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var n=document.cookie?document.cookie.split("; "):[],r={},o=0;o<n.length;o++){var i=n[o].split("="),c=i.slice(1).join("=");try{var u=decodeURIComponent(i[0]);if(r[u]=t.read(c,u),e===u)break}catch(e){}}return e?r[e]:r}},remove:function(e,t){o(e,"",r({},t,{expires:-1}))},withAttributes:function(t){return e(this.converter,r({},this.attributes,t))},withConverter:function(t){return e(r({},this.converter,t),this.attributes)}},{attributes:{value:Object.freeze(n)},converter:{value:Object.freeze(t)}})}({read:function(e){return'"'===e[0]&&(e=e.slice(1,-1)),e.replace(/(%[\dA-F]{2})+/gi,decodeURIComponent)},write:function(e){return encodeURIComponent(e).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,decodeURIComponent)}},{path:"/"})}}]);

View File

@@ -0,0 +1,196 @@
import Singleton from '../abstracts/Singleton';
/**
* Asset Loader.
*
* Provides simple asset loading functionality for Snowboard, making it easy to pre-load images or
* include JavaScript or CSS assets on the fly.
*
* By default, this loader will listen to any assets that have been requested to load in an AJAX
* response, such as responses from a component.
*
* You can also load assets manually by calling the following:
*
* ```js
* Snowboard.addPlugin('assetLoader', AssetLoader);
* Snowboard.assetLoader().processAssets(assets);
* ```
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class AssetLoader extends Singleton {
/**
* Event listeners.
*
* @returns {Object}
*/
listens() {
return {
ajaxLoadAssets: 'load',
};
}
/**
* Dependencies.
*
* @returns {Array}
*/
dependencies() {
return [
'url',
];
}
/**
* Process and load assets.
*
* The `assets` property of this method requires an object with any of the following keys and an
* array of paths:
*
* - `js`: An array of JavaScript URLs to load
* - `css`: An array of CSS stylesheet URLs to load
* - `img`: An array of image URLs to pre-load
*
* Both `js` and `css` files will be automatically injected, however `img` files will not.
*
* This method will return a Promise that resolves when all required assets are loaded. If an
* asset fails to load, this Promise will be rejected.
*
* ESLint *REALLY* doesn't like this code, but ignore it. It's the only way it works.
*
* @param {Object} assets
* @returns {Promise}
*/
async load(assets) {
if (assets.js && assets.js.length > 0) {
for (const script of assets.js) {
try {
await this.loadScript(script);
} catch (error) {
return Promise.reject(error);
}
}
}
if (assets.css && assets.css.length > 0) {
for (const style of assets.css) {
try {
await this.loadStyle(style);
} catch (error) {
return Promise.reject(error);
}
}
}
if (assets.img && assets.img.length > 0) {
for (const image of assets.img) {
try {
await this.loadImage(image);
} catch (error) {
return Promise.reject(error);
}
}
}
return Promise.resolve();
}
/**
* Injects and loads a JavaScript URL into the DOM.
*
* The script will be appended before the closing `</body>` tag.
*
* @param {String} script
* @returns {Promise}
*/
loadScript(script) {
return new Promise((resolve, reject) => {
// Resolve script URL
script = this.snowboard.url().asset(script);
// Check that script is not already loaded
const loaded = document.querySelector(`script[src="${script}"]`);
if (loaded) {
resolve();
return;
}
// Create script
const domScript = document.createElement('script');
domScript.setAttribute('type', 'text/javascript');
domScript.setAttribute('src', script);
domScript.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'script', script, domScript);
resolve();
});
domScript.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'script', script, domScript);
reject(new Error(`Unable to load script file: "${script}"`));
});
document.body.append(domScript);
});
}
/**
* Injects and loads a CSS stylesheet into the DOM.
*
* The stylesheet will be appended before the closing `</head>` tag.
*
* @param {String} style
* @returns {Promise}
*/
loadStyle(style) {
return new Promise((resolve, reject) => {
// Resolve style URL
style = this.snowboard.url().asset(style);
// Check that stylesheet is not already loaded
const loaded = document.querySelector(`link[rel="stylesheet"][href="${style}"]`);
if (loaded) {
resolve();
return;
}
// Create stylesheet
const domCss = document.createElement('link');
domCss.setAttribute('rel', 'stylesheet');
domCss.setAttribute('href', style);
domCss.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'style', style, domCss);
resolve();
});
domCss.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'style', style, domCss);
reject(new Error(`Unable to load stylesheet file: "${style}"`));
});
document.head.append(domCss);
});
}
/**
* Pre-loads an image.
*
* The image will not be injected into the DOM.
*
* @param {String} image
* @returns {Promise}
*/
loadImage(image) {
return new Promise((resolve, reject) => {
// Resolve script URL
image = this.snowboard.url().asset(image);
const img = new Image();
img.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'image', image, img);
resolve();
});
img.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'image', image, img);
reject(new Error(`Unable to load image file: "${image}"`));
});
img.src = image;
});
}
}

View File

@@ -0,0 +1,70 @@
import Singleton from '../abstracts/Singleton';
/**
* Allows attaching a loading class on elements that an AJAX request is targeting.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class AttachLoading extends Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['request'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ajaxStart: 'ajaxStart',
ajaxDone: 'ajaxDone',
};
}
ajaxStart(promise, request) {
if (!request.element) {
return;
}
if (request.element.tagName === 'FORM') {
const loadElements = request.element.querySelectorAll('[data-attach-loading]');
if (loadElements.length > 0) {
loadElements.forEach((element) => {
element.classList.add(this.getLoadingClass(element));
});
}
} else if (request.element.dataset.attachLoading !== undefined) {
request.element.classList.add(this.getLoadingClass(request.element));
}
}
ajaxDone(data, request) {
if (!request.element) {
return;
}
if (request.element.tagName === 'FORM') {
const loadElements = request.element.querySelectorAll('[data-attach-loading]');
if (loadElements.length > 0) {
loadElements.forEach((element) => {
element.classList.remove(this.getLoadingClass(element));
});
}
} else if (request.element.dataset.attachLoading !== undefined) {
request.element.classList.remove(this.getLoadingClass(request.element));
}
}
getLoadingClass(element) {
return (element.dataset.attachLoading !== undefined && element.dataset.attachLoading !== '')
? element.dataset.attachLoading
: 'wn-loading';
}
}

View File

@@ -0,0 +1,222 @@
import PluginBase from '../abstracts/PluginBase';
/**
* Data configuration provider.
*
* Provides a mechanism for passing configuration data through an element's data attributes. This
* is generally used for widgets or UI interactions to configure them.
*
* @copyright 2022 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class DataConfig extends PluginBase {
/**
* Constructor.
*
* @param {PluginBase} instance
* @param {HTMLElement} element
* @param {Object} localConfig
*/
construct(instance, element, localConfig) {
if (instance instanceof PluginBase === false) {
throw new Error('You must provide a Snowboard plugin to enable data configuration');
}
if (element instanceof HTMLElement === false) {
throw new Error('Data configuration can only be extracted from HTML elements');
}
this.instance = instance;
this.element = element;
this.localConfig = localConfig || {};
this.instanceConfig = {};
this.acceptedConfigs = {};
this.refresh();
}
/**
* Gets the config for this instance.
*
* If the `config` parameter is unspecified, returns the entire configuration.
*
* @param {string} config
*/
get(config) {
if (config === undefined) {
return this.instanceConfig;
}
if (this.instanceConfig[config] !== undefined) {
return this.instanceConfig[config];
}
return undefined;
}
/**
* Sets the config for this instance.
*
* This allows you to override, at runtime, any configuration value as necessary.
*
* @param {string} config
* @param {any} value
* @param {boolean} persist
*/
set(config, value, persist) {
if (config === undefined) {
throw new Error('You must provide a configuration key to set');
}
this.instanceConfig[config] = value;
if (persist === true) {
this.element.dataset[config] = value;
this.localConfig[config] = value;
}
}
/**
* Refreshes the configuration from the element.
*
* This will allow you to make changes to the data config on a DOM level and re-apply them
* to the config on the JavaScript side.
*/
refresh() {
this.acceptedConfigs = this.getAcceptedConfigs();
this.instanceConfig = this.processConfig();
}
/**
* Determines the available configurations that can be set through the data config.
*
* If an instance has an `acceptAllDataConfigs` property, set to `true`, then all data
* attributes will be available as configuration values. This can be a security concern, so
* tread carefully.
*
* Otherwise, available configurations will be determined by the keys available in an object
* returned by a `defaults()` method in the instance.
*
* @returns {string[]|boolean}
*/
getAcceptedConfigs() {
if (
this.instance.acceptAllDataConfigs !== undefined
&& this.instance.acceptAllDataConfigs === true
) {
return true;
}
if (
this.instance.defaults !== undefined
&& typeof this.instance.defaults === 'function'
&& typeof this.instance.defaults() === 'object'
) {
return Object.keys(this.instance.defaults());
}
return false;
}
/**
* Returns the default values for the instance.
*
* This will be an empty object if the instance either does not have a `defaults()` method, or
* the method itself does not return an object.
*
* @returns {object}
*/
getDefaults() {
if (
this.instance.defaults !== undefined
&& typeof this.instance.defaults === 'function'
&& typeof this.instance.defaults() === 'object'
) {
return this.instance.defaults();
}
return {};
}
/**
* Processes the configuration.
*
* Loads up the defaults, then populates it with any configuration values provided by the data
* attributes, based on the rules of the accepted configurations.
*
* This configuration object is then cached and available through `config.get()` calls.
*
* @returns {object}
*/
processConfig() {
const config = this.getDefaults();
if (this.acceptedConfigs === false) {
return config;
}
/* eslint-disable */
for (const key in this.element.dataset) {
if (this.acceptedConfigs === true || this.acceptedConfigs.includes(key)) {
config[key] = this.coerceValue(this.element.dataset[key]);
}
}
for (const key in this.localConfig) {
if (this.acceptedConfigs === true || this.acceptedConfigs.includes(key)) {
config[key] = this.localConfig[key];
}
}
/* eslint-enable */
return config;
}
/**
* Coerces configuration values for JavaScript.
*
* Takes the string value returned from the data attribute and coerces it into a more suitable
* type for JavaScript processing.
*
* @param {*} value
* @returns {*}
*/
coerceValue(value) {
const stringValue = String(value);
// Null value
if (stringValue === 'null') {
return null;
}
// Undefined value
if (stringValue === 'undefined') {
return undefined;
}
// Base64 value
if (stringValue.startsWith('base64:')) {
const base64str = stringValue.replace(/^base64:/, '');
const decoded = atob(base64str);
return this.coerceValue(decoded);
}
// Boolean value
if (['true', 'yes'].includes(stringValue.toLowerCase())) {
return true;
}
if (['false', 'no'].includes(stringValue.toLowerCase())) {
return false;
}
// Numeric value
if (/^[-+]?[0-9]+(\.[0-9]+)?$/.test(stringValue)) {
return Number(stringValue);
}
// JSON value
try {
return this.snowboard.jsonParser().parse(stringValue);
} catch (e) {
return (stringValue === '') ? true : stringValue;
}
}
}

View File

@@ -0,0 +1,150 @@
import PluginBase from '../abstracts/PluginBase';
/**
* Provides flash messages for the CMS.
*
* Flash messages will pop up at the top center of the page and will remain for 7 seconds by default. Hovering over
* the message will reset and pause the timer. Clicking on the flash message will dismiss it.
*
* Arguments:
* - "message": The content of the flash message. HTML is accepted.
* - "type": The type of flash message. This is appended as a class to the flash message itself.
* - "duration": How long the flash message will stay visible for, in seconds. Default: 7 seconds.
*
* Usage:
* Snowboard.flash('This is a flash message', 'info', 8);
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Flash extends PluginBase {
/**
* Constructor.
*
* @param {string} message
* @param {string} type
* @param {Number} duration
*/
construct(message, type, duration) {
this.message = message;
this.type = type || 'default';
this.duration = Number(duration || 7);
if (this.duration < 0) {
throw new Error('Flash duration must be a positive number, or zero');
}
this.clear();
this.timer = null;
this.flashTimer = null;
this.create();
}
/**
* Defines dependencies.
*
* @returns {string[]}
*/
dependencies() {
return ['transition'];
}
/**
* Destructor.
*
* This will ensure the flash message is removed and timeout is cleared if the module is removed.
*/
destruct() {
if (this.timer !== null) {
window.clearTimeout(this.timer);
}
if (this.flashTimer) {
this.flashTimer.remove();
}
if (this.flash) {
this.flash.remove();
this.flash = null;
this.flashTimer = null;
}
super.destruct();
}
/**
* Creates the flash message.
*/
create() {
this.snowboard.globalEvent('flash.create', this);
this.flash = document.createElement('DIV');
this.flash.innerHTML = this.message;
this.flash.classList.add('flash-message', this.type);
this.flash.removeAttribute('data-control');
this.flash.addEventListener('click', () => this.remove());
this.flash.addEventListener('mouseover', () => this.stopTimer());
this.flash.addEventListener('mouseout', () => this.startTimer());
if (this.duration > 0) {
this.flashTimer = document.createElement('DIV');
this.flashTimer.classList.add('flash-timer');
this.flash.appendChild(this.flashTimer);
} else {
this.flash.classList.add('no-timer');
}
// Add to body
document.body.appendChild(this.flash);
this.snowboard.transition(this.flash, 'show', () => {
this.startTimer();
});
}
/**
* Removes the flash message.
*/
remove() {
this.snowboard.globalEvent('flash.remove', this);
this.stopTimer();
this.snowboard.transition(this.flash, 'hide', () => {
this.flash.remove();
this.flash = null;
this.destruct();
});
}
/**
* Clears all flash messages available on the page.
*/
clear() {
document.querySelectorAll('body > div.flash-message').forEach((element) => element.remove());
}
/**
* Starts the timer for this flash message.
*/
startTimer() {
if (this.duration === 0) {
return;
}
this.timerTrans = this.snowboard.transition(this.flashTimer, 'timeout', null, `${this.duration}.0s`, true);
this.timer = window.setTimeout(() => this.remove(), this.duration * 1000);
}
/**
* Resets the timer for this flash message.
*/
stopTimer() {
if (this.timerTrans) {
this.timerTrans.cancel();
}
if (this.timer) {
window.clearTimeout(this.timer);
}
}
}

View File

@@ -0,0 +1,72 @@
import Singleton from '../abstracts/Singleton';
/**
* Defines a default listener for flash events.
*
* Connects the Flash plugin to various events that use flash messages.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class FlashListener extends Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['flash'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxErrorMessage: 'ajaxErrorMessage',
ajaxFlashMessages: 'ajaxFlashMessages',
};
}
/**
* Do flash messages for PHP flash responses.
*/
ready() {
document.querySelectorAll('[data-control="flash-message"]').forEach((element) => {
this.snowboard.flash(
element.innerHTML,
element.dataset.flashType,
element.dataset.flashDuration,
);
element.remove();
});
}
/**
* Shows a flash message for AJAX errors.
*
* @param {string} message
* @returns {Boolean}
*/
ajaxErrorMessage(message) {
this.snowboard.flash(message, 'error');
return false;
}
/**
* Shows flash messages returned directly from AJAX functionality.
*
* @param {Object} messages
*/
ajaxFlashMessages(messages) {
Object.entries(messages).forEach((entry) => {
const [cssClass, message] = entry;
this.snowboard.flash(message, cssClass);
});
return false;
}
}

View File

@@ -0,0 +1,215 @@
import Singleton from '../abstracts/Singleton';
/**
* Adds AJAX-driven form validation to Snowboard requests.
*
* Documentation for this feature can be found here:
* https://wintercms.com/docs/snowboard/extras#ajax-validation
*
* @copyright 2022 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class FormValidation extends Singleton {
/**
* Constructor.
*/
construct() {
this.errorBags = [];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxStart: 'clearValidation',
ajaxValidationErrors: 'doValidation',
};
}
/**
* Ready event handler.
*/
ready() {
this.collectErrorBags(document);
}
/**
* Retrieves validation errors from an AJAX response and passes them through to the error bags.
*
* This handler returns false to cancel any further validation handling, and prevents the flash
* message that is displayed by default for field errors in AJAX requests from showing.
*
* @param {HTMLFormElement} form
* @param {Object} invalidFields
* @param {Request} request
* @returns {Boolean}
*/
doValidation(form, invalidFields, request) {
if (request.element && request.element.dataset.requestValidate === undefined) {
return null;
}
if (!form) {
return null;
}
const errorBags = this.errorBags.filter((errorBag) => errorBag.form === form);
errorBags.forEach((errorBag) => {
this.showErrorBag(errorBag, invalidFields);
});
return false;
}
/**
* Clears any validation errors in the given form.
*
* @param {Promise} promise
* @param {Request} request
* @returns {void}
*/
clearValidation(promise, request) {
if (request.element && request.element.dataset.requestValidate === undefined) {
return;
}
if (!request.form) {
return;
}
const errorBags = this.errorBags.filter((errorBag) => errorBag.form === request.form);
errorBags.forEach((errorBag) => {
this.hideErrorBag(errorBag);
});
}
/**
* Collects error bags (elements with "data-validate-error" attribute) and links them to a
* placeholder and form.
*
* The error bags will be initially hidden, and will only show when validation errors occur.
*
* @param {HTMLElement} rootNode
*/
collectErrorBags(rootNode) {
rootNode.querySelectorAll('[data-validate-error], [data-validate-for]').forEach((errorBag) => {
const form = errorBag.closest('form[data-request-validate]');
// If this error bag does not reside within a validating form, remove it
if (!form) {
errorBag.parentNode.removeChild(errorBag);
return;
}
// Find message list node, if available
let messageListElement = null;
if (errorBag.matches('[data-validate-error]')) {
messageListElement = errorBag.querySelector('[data-message]');
}
// Create a placeholder node
const placeholder = document.createComment('');
// Register error bag and replace with placeholder
const errorBagData = {
element: errorBag,
form,
validateFor: (errorBag.dataset.validateFor)
? errorBag.dataset.validateFor.split(/\s*,\s*/)
: '*',
placeholder,
messageListElement: (messageListElement)
? messageListElement.cloneNode(true)
: null,
messageListAnchor: null,
customMessage: (errorBag.dataset.validateFor)
? (errorBag.textContent !== '' || errorBag.childNodes.length > 0)
: false,
};
// If an message list element exists, create another placeholder to act as an anchor point
if (messageListElement) {
const messageListAnchor = document.createComment('');
messageListElement.parentNode.replaceChild(messageListAnchor, messageListElement);
errorBagData.messageListAnchor = messageListAnchor;
}
errorBag.parentNode.replaceChild(placeholder, errorBag);
this.errorBags.push(errorBagData);
});
}
/**
* Hides an error bag, replacing the error messages with a placeholder node.
*
* @param {Object} errorBag
*/
hideErrorBag(errorBag) {
if (errorBag.element.isConnected) {
errorBag.element.parentNode.replaceChild(errorBag.placeholder, errorBag.element);
}
}
/**
* Shows an error bag with the given invalid fields.
*
* @param {Object} errorBag
* @param {Object} invalidFields
*/
showErrorBag(errorBag, invalidFields) {
if (!this.errorBagValidatesField(errorBag, invalidFields)) {
return;
}
if (!errorBag.element.isConnected) {
errorBag.placeholder.parentNode.replaceChild(errorBag.element, errorBag.placeholder);
}
if (errorBag.validateFor !== '*') {
if (!errorBag.customMessage) {
const firstField = Object.keys(invalidFields)
.filter((field) => errorBag.validateFor.includes(field))
.shift();
[errorBag.element.innerHTML] = invalidFields[firstField];
}
} else if (errorBag.messageListElement) {
// Remove previous error messages
errorBag.element.querySelectorAll('[data-validation-message]').forEach((message) => {
message.parentNode.removeChild(message);
});
Object.entries(invalidFields).forEach((entry) => {
const [, errors] = entry;
errors.forEach((error) => {
const messageElement = errorBag.messageListElement.cloneNode(true);
messageElement.dataset.validationMessage = '';
messageElement.innerHTML = error;
errorBag.messageListAnchor.after(messageElement);
});
});
} else {
[errorBag.element.innerHTML] = invalidFields[Object.keys(invalidFields).shift()];
}
}
/**
* Determines if a given error bag applies for the given invalid fields.
*
* @param {Object} errorBag
* @param {Object} invalidFields
* @returns {Boolean}
*/
errorBagValidatesField(errorBag, invalidFields) {
if (errorBag.validateFor === '*') {
return true;
}
return Object.keys(invalidFields)
.filter((field) => errorBag.validateFor.includes(field))
.length > 0;
}
}

View File

@@ -0,0 +1,94 @@
import Singleton from '../abstracts/Singleton';
/**
* Displays a stripe at the top of the page that indicates loading.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class StripeLoader extends Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['request'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxStart: 'ajaxStart',
};
}
ready() {
this.counter = 0;
this.createStripe();
}
ajaxStart(promise, request) {
if (request.loading === false || request.options.stripe === false) {
return;
}
this.show();
promise.then(() => {
this.hide();
}).catch(() => {
this.hide();
});
}
createStripe() {
this.indicator = document.createElement('DIV');
this.stripe = document.createElement('DIV');
this.stripeLoaded = document.createElement('DIV');
this.indicator.classList.add('stripe-loading-indicator', 'loaded');
this.stripe.classList.add('stripe');
this.stripeLoaded.classList.add('stripe-loaded');
this.indicator.appendChild(this.stripe);
this.indicator.appendChild(this.stripeLoaded);
document.body.appendChild(this.indicator);
}
show() {
this.counter += 1;
const newStripe = this.stripe.cloneNode(true);
this.indicator.appendChild(newStripe);
this.stripe.remove();
this.stripe = newStripe;
if (this.counter > 1) {
return;
}
this.indicator.classList.remove('loaded');
document.body.classList.add('wn-loading');
}
hide(force) {
this.counter -= 1;
if (force === true) {
this.counter = 0;
}
if (this.counter <= 0) {
this.indicator.classList.add('loaded');
document.body.classList.remove('wn-loading');
}
}
}

View File

@@ -0,0 +1,38 @@
import Singleton from '../abstracts/Singleton';
/**
* Embeds the "extras" stylesheet into the page, if it is not loaded through the theme.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class StylesheetLoader extends Singleton {
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
};
}
ready() {
let stylesLoaded = false;
// Determine if stylesheet is already loaded
document.querySelectorAll('link[rel="stylesheet"]').forEach((css) => {
if (css.href.endsWith('/modules/system/assets/css/snowboard.extras.css')) {
stylesLoaded = true;
}
});
if (!stylesLoaded) {
const stylesheet = document.createElement('link');
stylesheet.setAttribute('rel', 'stylesheet');
stylesheet.setAttribute('href', this.snowboard.url().asset('/modules/system/assets/css/snowboard.extras.css'));
document.head.appendChild(stylesheet);
}
}
}

View File

@@ -0,0 +1,206 @@
import PluginBase from '../abstracts/PluginBase';
/**
* Provides transition support for elements.
*
* Transition allows CSS transitions to be controlled and callbacks to be run once completed. It works similar to Vue
* transitions with 3 stages of transition, and classes assigned to the element with the transition name suffixed with
* the stage of transition:
*
* - `in`: A class assigned to the element for the first frame of the transition, removed afterwards. This should be
* used to define the initial state of the transition.
* - `active`: A class assigned to the element for the duration of the transition. This should be used to define the
* transition itself.
* - `out`: A class assigned to the element after the first frame of the transition and kept to the end of the
* transition. This should define the end state of the transition.
*
* Usage:
* Snowboard.transition(document.element, 'transition', () => {
* console.log('Remove element after 7 seconds');
* this.remove();
* }, '7s');
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Transition extends PluginBase {
/**
* Constructor.
*
* @param {HTMLElement} element The element to transition
* @param {string} transition The name of the transition, this prefixes the stages of transition.
* @param {Function} callback An optional callback to call when the transition ends.
* @param {Number} duration An optional override on the transition duration. Must be specified as 's' (secs) or 'ms' (msecs).
* @param {Boolean} trailTo If true, the "out" class will remain after the end of the transition.
*/
construct(element, transition, callback, duration, trailTo) {
if (element instanceof HTMLElement === false) {
throw new Error('A HTMLElement must be provided for transitioning');
}
this.element = element;
if (typeof transition !== 'string') {
throw new Error('Transition name must be specified as a string');
}
this.transition = transition;
if (callback && typeof callback !== 'function') {
throw new Error('Callback must be a valid function');
}
this.callback = callback;
if (duration) {
this.duration = this.parseDuration(duration);
} else {
this.duration = null;
}
this.trailTo = (trailTo === true);
this.doTransition();
}
/**
* Maps event classes to the given transition state.
*
* @param {...any} args
* @returns {Array}
*/
eventClasses(...args) {
const eventClasses = {
in: `${this.transition}-in`,
active: `${this.transition}-active`,
out: `${this.transition}-out`,
};
if (args.length === 0) {
return Object.values(eventClasses);
}
const returnClasses = [];
Object.entries(eventClasses).forEach((entry) => {
const [key, value] = entry;
if (args.indexOf(key) !== -1) {
returnClasses.push(value);
}
});
return returnClasses;
}
/**
* Executes the transition.
*
* @returns {void}
*/
doTransition() {
// Add duration override
if (this.duration !== null) {
this.element.style.transitionDuration = this.duration;
}
this.resetClasses();
// Start transition - show "in" and "active" classes
this.eventClasses('in', 'active').forEach((eventClass) => {
this.element.classList.add(eventClass);
});
window.requestAnimationFrame(() => {
// Ensure a transition exists
if (window.getComputedStyle(this.element)['transition-duration'] !== '0s') {
// Listen for the transition to end
this.element.addEventListener('transitionend', () => this.onTransitionEnd(), {
once: true,
});
window.requestAnimationFrame(() => {
this.element.classList.remove(this.eventClasses('in')[0]);
this.element.classList.add(this.eventClasses('out')[0]);
});
} else {
this.resetClasses();
if (this.callback) {
this.callback.apply(this.element);
}
this.destruct();
}
});
}
/**
* Callback function when the transition ends.
*
* When a transition ends, the instance of the transition is automatically destructed.
*
* @returns {void}
*/
onTransitionEnd() {
this.eventClasses('active', (!this.trailTo) ? 'out' : '').forEach((eventClass) => {
this.element.classList.remove(eventClass);
});
if (this.callback) {
this.callback.apply(this.element);
}
// Remove duration override
if (this.duration !== null) {
this.element.style.transitionDuration = null;
}
this.destruct();
}
/**
* Cancels a transition.
*
* @returns {void}
*/
cancel() {
this.element.removeEventListener('transitionend', () => this.onTransitionEnd, {
once: true,
});
this.resetClasses();
// Remove duration override
if (this.duration !== null) {
this.element.style.transitionDuration = null;
}
// Call destructor
this.destruct();
}
/**
* Resets the classes, removing any transition classes.
*
* @returns {void}
*/
resetClasses() {
this.eventClasses().forEach((eventClass) => {
this.element.classList.remove(eventClass);
});
}
/**
* Parses a given duration and converts it to a "ms" value.
*
* @param {String} duration
* @returns {String}
*/
parseDuration(duration) {
const parsed = /^([0-9]+(\.[0-9]+)?)(m?s)?$/.exec(duration);
const amount = Number(parsed[1]);
const unit = (parsed[3] === 's')
? 'sec'
: 'msec';
return (unit === 'sec')
? `${amount * 1000}ms`
: `${Math.floor(amount)}ms`;
}
}

View File

@@ -0,0 +1,43 @@
/**
* Internal proxy for Snowboard.
*
* This handler wraps the Snowboard instance that is passed to the constructor of plugin instances.
* It prevents access to the following methods:
* - `attachAbstracts`: No need to attach abstracts again.
* - `loadUtilties`: No need to load utilities again.
* - `initialise`: Snowboard is already initialised.
* - `initialiseSingletons`: Singletons are already initialised.
*/
export default {
get(target, prop, receiver) {
if (typeof prop === 'string') {
const propLower = prop.toLowerCase();
if (['attachAbstracts', 'loadUtilities', 'initialise', 'initialiseSingletons'].includes(prop)) {
throw new Error(`You cannot use the "${prop}" Snowboard method within a plugin.`);
}
if (target.hasPlugin(propLower)) {
return (...params) => Reflect.get(target, 'plugins')[propLower].getInstance(...params);
}
}
return Reflect.get(target, prop, receiver);
},
has(target, prop) {
if (typeof prop === 'string') {
const propLower = prop.toLowerCase();
if (['attachAbstracts', 'loadUtilities', 'initialise', 'initialiseSingletons'].includes(prop)) {
return false;
}
if (target.hasPlugin(propLower)) {
return true;
}
}
return Reflect.has(target, prop);
},
};

View File

@@ -0,0 +1,293 @@
import PluginBase from '../abstracts/PluginBase';
import Singleton from '../abstracts/Singleton';
import InnerProxyHandler from './InnerProxyHandler';
/**
* Plugin loader class.
*
* This is a provider (factory) class for a single plugin and provides the link between Snowboard framework functionality
* and the underlying plugin instances. It also provides some basic mocking of plugin methods for testing.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class PluginLoader {
/**
* Constructor.
*
* Binds the Winter framework to the instance.
*
* @param {string} name
* @param {Snowboard} snowboard
* @param {PluginBase} instance
*/
constructor(name, snowboard, instance) {
this.name = name;
this.snowboard = new Proxy(
snowboard,
InnerProxyHandler,
);
this.instance = instance;
// Freeze instance that has been inserted into this loader
Object.freeze(this.instance);
this.instances = [];
this.singleton = {
initialised: false,
};
// Prevent further extension of the singleton status object
Object.seal(this.singleton);
this.mocks = {};
this.originalFunctions = {};
// Freeze loader itself
Object.freeze(PluginLoader.prototype);
Object.freeze(this);
}
/**
* Determines if the current plugin has a specific method available.
*
* Returns false if the current plugin is a callback function.
*
* @param {string} methodName
* @returns {boolean}
*/
hasMethod(methodName) {
if (this.isFunction()) {
return false;
}
return (typeof this.instance.prototype[methodName] === 'function');
}
/**
* Calls a prototype method for a plugin. This should generally be used for "static" calls.
*
* @param {string} methodName
* @param {...} args
* @returns {any}
*/
callMethod(...parameters) {
if (this.isFunction()) {
return null;
}
const args = parameters;
const methodName = args.shift();
return this.instance.prototype[methodName](args);
}
/**
* Returns an instance of the current plugin.
*
* - If this is a callback function plugin, the function will be returned.
* - If this is a singleton, the single instance of the plugin will be returned.
*
* @returns {PluginBase|Function}
*/
getInstance(...parameters) {
if (this.isFunction()) {
return this.instance(...parameters);
}
if (!this.dependenciesFulfilled()) {
const unmet = this.getDependencies().filter((item) => !this.snowboard.getPluginNames().includes(item));
throw new Error(`The "${this.name}" plugin requires the following plugins: ${unmet.join(', ')}`);
}
if (this.isSingleton()) {
if (this.instances.length === 0) {
this.initialiseSingleton(...parameters);
}
// Apply mocked methods
if (Object.keys(this.mocks).length > 0) {
Object.entries(this.originalFunctions).forEach((entry) => {
const [methodName, callback] = entry;
this.instances[0][methodName] = callback;
});
Object.entries(this.mocks).forEach((entry) => {
const [methodName, callback] = entry;
this.instances[0][methodName] = (...params) => callback(this, ...params);
});
}
return this.instances[0];
}
// Apply mocked methods to prototype
if (Object.keys(this.mocks).length > 0) {
Object.entries(this.originalFunctions).forEach((entry) => {
const [methodName, callback] = entry;
this.instance.prototype[methodName] = callback;
});
Object.entries(this.mocks).forEach((entry) => {
const [methodName, callback] = entry;
this.instance.prototype[methodName] = (...params) => callback(this, ...params);
});
}
const newInstance = new this.instance(this.snowboard, ...parameters);
newInstance.detach = () => this.instances.splice(this.instances.indexOf(newInstance), 1);
newInstance.construct(...parameters);
this.instances.push(newInstance);
return newInstance;
}
/**
* Gets all instances of the current plugin.
*
* If this plugin is a callback function plugin, an empty array will be returned.
*
* @returns {PluginBase[]}
*/
getInstances() {
if (this.isFunction()) {
return [];
}
return this.instances;
}
/**
* Determines if the current plugin is a simple callback function.
*
* @returns {boolean}
*/
isFunction() {
return (typeof this.instance === 'function' && this.instance.prototype instanceof PluginBase === false);
}
/**
* Determines if the current plugin is a singleton.
*
* @returns {boolean}
*/
isSingleton() {
return this.instance.prototype instanceof Singleton === true;
}
/**
* Determines if a singleton has been initialised.
*
* Normal plugins will always return true.
*
* @returns {boolean}
*/
isInitialised() {
if (!this.isSingleton()) {
return true;
}
return this.singleton.initialised;
}
/**
* Initialises the singleton instance.
*
* @returns {void}
*/
initialiseSingleton(...parameters) {
if (!this.isSingleton()) {
return;
}
const newInstance = new this.instance(this.snowboard, ...parameters);
newInstance.detach = () => this.instances.splice(this.instances.indexOf(newInstance), 1);
newInstance.construct(...parameters);
this.instances.push(newInstance);
this.singleton.initialised = true;
}
/**
* Gets the dependencies of the current plugin.
*
* @returns {string[]}
*/
getDependencies() {
// Callback functions cannot have dependencies.
if (this.isFunction()) {
return [];
}
// No dependency method specified.
if (typeof this.instance.prototype.dependencies !== 'function') {
return [];
}
return this.instance.prototype.dependencies().map((item) => item.toLowerCase());
}
/**
* Determines if the current plugin has all its dependencies fulfilled.
*
* @returns {boolean}
*/
dependenciesFulfilled() {
const dependencies = this.getDependencies();
let fulfilled = true;
dependencies.forEach((plugin) => {
if (!this.snowboard.hasPlugin(plugin)) {
fulfilled = false;
}
});
return fulfilled;
}
/**
* Allows a method of an instance to be mocked for testing.
*
* This mock will be applied for the life of an instance. For singletons, the mock will be applied for the life
* of the page.
*
* Mocks cannot be applied to callback function plugins.
*
* @param {string} methodName
* @param {Function} callback
*/
mock(methodName, callback) {
if (this.isFunction()) {
return;
}
if (!this.instance.prototype[methodName]) {
throw new Error(`Function "${methodName}" does not exist and cannot be mocked`);
}
this.mocks[methodName] = callback;
this.originalFunctions[methodName] = this.instance.prototype[methodName];
if (this.isSingleton() && this.instances.length === 0) {
this.initialiseSingleton();
// Apply mocked method
this.instances[0][methodName] = (...parameters) => callback(this, ...parameters);
}
}
/**
* Removes a mock callback from future instances.
*
* @param {string} methodName
*/
unmock(methodName) {
if (this.isFunction()) {
return;
}
if (!this.mocks[methodName]) {
return;
}
if (this.isSingleton()) {
this.instances[0][methodName] = this.originalFunctions[methodName];
}
delete this.mocks[methodName];
delete this.originalFunctions[methodName];
}
}

View File

@@ -0,0 +1,25 @@
export default {
get(target, prop, receiver) {
if (typeof prop === 'string') {
const propLower = prop.toLowerCase();
if (target.hasPlugin(propLower)) {
return (...params) => Reflect.get(target, 'plugins')[propLower].getInstance(...params);
}
}
return Reflect.get(target, prop, receiver);
},
has(target, prop) {
if (typeof prop === 'string') {
const propLower = prop.toLowerCase();
if (target.hasPlugin(propLower)) {
return true;
}
}
return Reflect.has(target, prop);
},
};

View File

@@ -0,0 +1,595 @@
import PluginBase from '../abstracts/PluginBase';
import Singleton from '../abstracts/Singleton';
import PluginLoader from './PluginLoader';
import Cookie from '../utilities/Cookie';
import JsonParser from '../utilities/JsonParser';
import Sanitizer from '../utilities/Sanitizer';
import Url from '../utilities/Url';
/**
* Snowboard - the Winter JavaScript framework.
*
* This class represents the base of a modern take on the Winter JS framework, being fully extensible and taking advantage
* of modern JavaScript features by leveraging the Laravel Mix compilation framework. It also is coded up to remove the
* dependency of jQuery.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
* @link https://wintercms.com/docs/snowboard/introduction
*/
export default class Snowboard {
/**
* Constructor.
*
* @param {boolean} autoSingletons Automatically load singletons when DOM is ready. Default: `true`.
* @param {boolean} debug Whether debugging logs should be shown. Default: `false`.
*/
constructor(autoSingletons, debug) {
this.debugEnabled = (typeof debug === 'boolean' && debug === true);
this.autoInitSingletons = (typeof autoSingletons === 'boolean' && autoSingletons === false);
this.plugins = {};
this.listeners = {};
this.foundBaseUrl = null;
this.readiness = {
dom: false,
};
// Seal readiness from being added to further, but allow the properties to be modified.
Object.seal(this.readiness);
this.attachAbstracts();
// Freeze the Snowboard class to prevent further modifications.
Object.freeze(Snowboard.prototype);
Object.freeze(this);
this.loadUtilities();
this.initialise();
this.debug('Snowboard framework initialised');
}
/**
* Attaches abstract classes as properties of the Snowboard class.
*
* This will allow Javascript functionality with no build process to still extend these abstracts by prefixing
* them with "Snowboard".
*
* ```
* class MyClass extends Snowboard.PluginBase {
* ...
* }
* ```
*/
attachAbstracts() {
this.PluginBase = PluginBase;
this.Singleton = Singleton;
Object.freeze(this.PluginBase.prototype);
Object.freeze(this.PluginBase);
Object.freeze(this.Singleton.prototype);
Object.freeze(this.Singleton);
}
/**
* Loads the default utilities.
*/
loadUtilities() {
this.addPlugin('cookie', Cookie);
this.addPlugin('jsonParser', JsonParser);
this.addPlugin('sanitizer', Sanitizer);
this.addPlugin('url', Url);
}
/**
* Initialises the framework.
*
* Attaches a listener for the DOM being ready and triggers a global "ready" event for plugins to begin attaching
* themselves to the DOM.
*/
initialise() {
window.addEventListener('DOMContentLoaded', () => {
if (this.autoInitSingletons) {
this.initialiseSingletons();
}
this.globalEvent('ready');
this.readiness.dom = true;
});
}
/**
* Initialises an instance of every singleton.
*/
initialiseSingletons() {
Object.values(this.plugins).forEach((plugin) => {
if (plugin.isSingleton() && plugin.dependenciesFulfilled()) {
plugin.initialiseSingleton();
}
});
}
/**
* Adds a plugin to the framework.
*
* Plugins are the cornerstone for additional functionality for Snowboard. A plugin must either be an ES2015 class
* that extends the PluginBase or Singleton abstract classes, or a simple callback function.
*
* When a plugin is added, it is automatically assigned as a new magic method in the Snowboard class using the name
* parameter, and can be called via this method. This method will always be the "lowercase" version of this name.
*
* For example, if a plugin is assigned to the name "myPlugin", it can be called via `Snowboard.myplugin()`.
*
* @param {string} name
* @param {PluginBase|Function} instance
*/
addPlugin(name, instance) {
const lowerName = name.toLowerCase();
if (this.hasPlugin(lowerName)) {
throw new Error(`A plugin called "${name}" is already registered.`);
}
if (typeof instance !== 'function' && instance instanceof PluginBase === false) {
throw new Error('The provided plugin must extend the PluginBase class, or must be a callback function.');
}
if (this[name] !== undefined || this[lowerName] !== undefined) {
throw new Error('The given name is already in use for a property or method of the Snowboard class.');
}
this.plugins[lowerName] = new PluginLoader(lowerName, this, instance);
this.debug(`Plugin "${name}" registered`);
// Check if any singletons now have their dependencies fulfilled, and fire their "ready" handler if we're
// in a ready state.
Object.values(this.getPlugins()).forEach((plugin) => {
if (
plugin.isSingleton()
&& !plugin.isInitialised()
&& plugin.dependenciesFulfilled()
&& plugin.hasMethod('listens')
&& Object.keys(plugin.callMethod('listens')).includes('ready')
&& this.readiness.dom
) {
const readyMethod = plugin.callMethod('listens').ready;
plugin.callMethod(readyMethod);
}
});
}
/**
* Removes a plugin.
*
* Removes a plugin from Snowboard, calling the destructor method for all active instances of the plugin.
*
* @param {string} name
* @returns {void}
*/
removePlugin(name) {
const lowerName = name.toLowerCase();
if (!this.hasPlugin(lowerName)) {
this.debug(`Plugin "${name}" already removed`);
return;
}
// Call destructors for all instances
this.plugins[lowerName].getInstances().forEach((instance) => {
instance.destruct();
});
delete this.plugins[lowerName];
delete this[lowerName];
delete this[name];
this.debug(`Plugin "${name}" removed`);
}
/**
* Determines if a plugin has been registered and is active.
*
* A plugin that is still waiting for dependencies to be registered will not be active.
*
* @param {string} name
* @returns {boolean}
*/
hasPlugin(name) {
const lowerName = name.toLowerCase();
return (this.plugins[lowerName] !== undefined);
}
/**
* Returns an array of registered plugins as PluginLoader objects.
*
* @returns {PluginLoader[]}
*/
getPlugins() {
return this.plugins;
}
/**
* Returns an array of registered plugins, by name.
*
* @returns {string[]}
*/
getPluginNames() {
return Object.keys(this.plugins);
}
/**
* Returns a PluginLoader object of a given plugin.
*
* @returns {PluginLoader}
*/
getPlugin(name) {
const lowerName = name.toLowerCase();
if (!this.hasPlugin(lowerName)) {
throw new Error(`No plugin called "${lowerName}" has been registered.`);
}
return this.plugins[lowerName];
}
/**
* Finds all plugins that listen to the given event.
*
* This works for both normal and promise events. It does NOT check that the plugin's listener actually exists.
*
* @param {string} eventName
* @returns {string[]} The name of the plugins that are listening to this event.
*/
listensToEvent(eventName) {
const plugins = [];
Object.entries(this.plugins).forEach((entry) => {
const [name, plugin] = entry;
if (plugin.isFunction()) {
return;
}
if (!plugin.dependenciesFulfilled()) {
return;
}
if (!plugin.hasMethod('listens')) {
return;
}
const listeners = plugin.callMethod('listens');
if (typeof listeners[eventName] === 'string' || typeof listeners[eventName] === 'function') {
plugins.push(name);
}
});
return plugins;
}
/**
* Add a simple ready listener.
*
* Synonymous with jQuery's "$(document).ready()" functionality, this allows inline scripts to
* attach themselves to Snowboard immediately but only fire when the DOM is ready.
*
* @param {Function} callback
*/
ready(callback) {
if (this.readiness.dom) {
callback();
}
this.on('ready', callback);
}
/**
* Adds a simple listener for an event.
*
* This can be used for ad-hoc scripts that don't need a full plugin. The given callback will be
* called when the event name provided fires. This works for both normal and Promise events. For
* a Promise event, your callback must return a Promise.
*
* @param {String} eventName
* @param {Function} callback
*/
on(eventName, callback) {
if (!this.listeners[eventName]) {
this.listeners[eventName] = [];
}
if (!this.listeners[eventName].includes(callback)) {
this.listeners[eventName].push(callback);
}
}
/**
* Removes a simple listener for an event.
*
* @param {String} eventName
* @param {Function} callback
*/
off(eventName, callback) {
if (!this.listeners[eventName]) {
return;
}
const index = this.listeners[eventName].indexOf(callback);
if (index === -1) {
return;
}
this.listeners[eventName].splice(index, 1);
}
/**
* Calls a global event to all registered plugins.
*
* If any plugin returns a `false`, the event is considered cancelled.
*
* @param {string} eventName
* @returns {boolean} If event was not cancelled
*/
globalEvent(eventName, ...parameters) {
this.debug(`Calling global event "${eventName}"`, ...parameters);
// Find plugins listening to the event.
const listeners = this.listensToEvent(eventName);
if (listeners.length === 0) {
this.debug(`No listeners found for global event "${eventName}"`);
}
this.debug(`Listeners found for global event "${eventName}": ${listeners.join(', ')}`);
let cancelled = false;
listeners.forEach((name) => {
const plugin = this.getPlugin(name);
if (plugin.isFunction()) {
return;
}
if (plugin.isSingleton() && plugin.getInstances().length === 0) {
plugin.initialiseSingleton();
}
const listenMethod = plugin.callMethod('listens')[eventName];
// Call event handler methods for all plugins, if they have a method specified for the event.
plugin.getInstances().forEach((instance) => {
// If a plugin has cancelled the event, no further plugins are considered.
if (cancelled) {
return;
}
if (typeof listenMethod === 'function') {
try {
const result = listenMethod.apply(instance, parameters);
if (result === false) {
cancelled = true;
}
} catch (error) {
this.error(
`Error thrown in "${eventName}" event by "${name}" plugin.`,
error,
);
}
} else if (typeof listenMethod === 'string') {
if (!instance[listenMethod]) {
throw new Error(`Missing "${listenMethod}" method in "${name}" plugin`);
}
try {
if (instance[listenMethod](...parameters) === false) {
cancelled = true;
this.debug(`Global event "${eventName}" cancelled by "${name}" plugin`);
}
} catch (error) {
this.error(
`Error thrown in "${eventName}" event by "${name}" plugin.`,
error,
);
}
} else {
this.error(`Listen method for "${eventName}" event in "${name}" plugin is not a function or string.`);
}
});
});
// Find ad-hoc listeners for this event.
if (!cancelled && this.listeners[eventName] && this.listeners[eventName].length > 0) {
this.debug(`Found ${this.listeners[eventName].length} ad-hoc listener(s) for global event "${eventName}"`);
this.listeners[eventName].forEach((listener) => {
// If a listener has cancelled the event, no further listeners are considered.
if (cancelled) {
return;
}
try {
if (listener(...parameters) === false) {
cancelled = true;
this.debug(`Global event "${eventName} cancelled by an ad-hoc listener.`);
}
} catch (error) {
this.error(
`Error thrown in "${eventName}" event by an ad-hoc listener.`,
error,
);
}
});
}
return !cancelled;
}
/**
* Calls a global event to all registered plugins, expecting a Promise to be returned by all.
*
* This collates all plugins responses into one large Promise that either expects all to be resolved, or one to reject.
* If no listeners are found, a resolved Promise is returned.
*
* @param {string} eventName
*/
globalPromiseEvent(eventName, ...parameters) {
this.debug(`Calling global promise event "${eventName}"`);
// Find plugins listening to this event.
const listeners = this.listensToEvent(eventName);
if (listeners.length === 0) {
this.debug(`No listeners found for global promise event "${eventName}"`);
}
this.debug(`Listeners found for global promise event "${eventName}": ${listeners.join(', ')}`);
const promises = [];
listeners.forEach((name) => {
const plugin = this.getPlugin(name);
if (plugin.isFunction()) {
return;
}
if (plugin.isSingleton() && plugin.getInstances().length === 0) {
plugin.initialiseSingleton();
}
const listenMethod = plugin.callMethod('listens')[eventName];
// Call event handler methods for all plugins, if they have a method specified for the event.
plugin.getInstances().forEach((instance) => {
if (typeof listenMethod === 'function') {
try {
const instancePromise = listenMethod.apply(instance, parameters);
if (instancePromise instanceof Promise === false) {
return;
}
promises.push(instancePromise);
} catch (error) {
this.error(
`Error thrown in "${eventName}" event by "${name}" plugin.`,
error,
);
}
} else if (typeof listenMethod === 'string') {
if (!instance[listenMethod]) {
throw new Error(`Missing "${listenMethod}" method in "${name}" plugin`);
}
try {
const instancePromise = instance[listenMethod](...parameters);
if (instancePromise instanceof Promise === false) {
return;
}
promises.push(instancePromise);
} catch (error) {
this.error(
`Error thrown in "${eventName}" promise event by "${name}" plugin.`,
error,
);
}
} else {
this.error(`Listen method for "${eventName}" event in "${name}" plugin is not a function or string.`);
}
});
});
// Find ad-hoc listeners listening to this event.
if (this.listeners[eventName] && this.listeners[eventName].length > 0) {
this.debug(`Found ${this.listeners[eventName].length} ad-hoc listener(s) for global promise event "${eventName}"`);
this.listeners[eventName].forEach((listener) => {
try {
const listenerPromise = listener(...parameters);
if (listenerPromise instanceof Promise === false) {
return;
}
promises.push(listenerPromise);
} catch (error) {
this.error(
`Error thrown in "${eventName}" promise event by an ad-hoc listener.`,
error,
);
}
});
}
if (promises.length === 0) {
return Promise.resolve();
}
return Promise.all(promises);
}
/**
* Log a styled message in the console.
*
* Includes parameters and a stack trace.
*
* @returns {void}
*/
logMessage(color, bold, message, ...parameters) {
/* eslint-disable */
console.groupCollapsed(
'%c[Snowboard]',
`color: ${color}; font-weight: ${(bold) ? 'bold' : 'normal'};`,
message
);
if (parameters.length) {
console.groupCollapsed(
`%cParameters %c(${parameters.length})`,
'color: rgb(45, 167, 199); font-weight: bold;',
'color: rgb(88, 88, 88); font-weight: normal;'
);
let index = 0;
parameters.forEach((param) => {
index += 1;
console.log(`%c${index}:`, 'color: rgb(88, 88, 88); font-weight: normal;', param);
});
console.groupEnd();
console.groupCollapsed('%cTrace', 'color: rgb(45, 167, 199); font-weight: bold;');
console.trace();
console.groupEnd();
} else {
console.trace();
}
console.groupEnd();
/* eslint-enable */
}
/**
* Log a message.
*
* @returns {void}
*/
log(message, ...parameters) {
this.logMessage('rgb(45, 167, 199)', false, message, ...parameters);
}
/**
* Log a debug message.
*
* These messages are only shown when debugging is enabled.
*
* @returns {void}
*/
debug(message, ...parameters) {
if (!this.debugEnabled) {
return;
}
this.logMessage('rgb(45, 167, 199)', false, message, ...parameters);
}
/**
* Logs an error message.
*
* @returns {void}
*/
error(message, ...parameters) {
this.logMessage('rgb(229, 35, 35)', true, message, ...parameters);
}
}

View File

@@ -0,0 +1,21 @@
import Flash from './extras/Flash';
import Transition from './extras/Transition';
import AttachLoading from './extras/AttachLoading';
import StripeLoader from './extras/StripeLoader';
import StylesheetLoader from './extras/StylesheetLoader';
import AssetLoader from './extras/AssetLoader';
import DataConfig from './extras/DataConfig';
if (window.Snowboard === undefined) {
throw new Error('Snowboard must be loaded in order to use the extra plugins.');
}
((Snowboard) => {
Snowboard.addPlugin('assetLoader', AssetLoader);
Snowboard.addPlugin('dataConfig', DataConfig);
Snowboard.addPlugin('extrasStyles', StylesheetLoader);
Snowboard.addPlugin('transition', Transition);
Snowboard.addPlugin('flash', Flash);
Snowboard.addPlugin('attachLoading', AttachLoading);
Snowboard.addPlugin('stripeLoader', StripeLoader);
})(window.Snowboard);

View File

@@ -0,0 +1,14 @@
import Snowboard from './main/Snowboard';
import ProxyHandler from './main/ProxyHandler';
((window) => {
const snowboard = new Proxy(
new Snowboard(true, true),
ProxyHandler,
);
// Cover all aliases
window.snowboard = snowboard;
window.Snowboard = snowboard;
window.SnowBoard = snowboard;
})(window);

View File

@@ -0,0 +1,14 @@
import Snowboard from './main/Snowboard';
import ProxyHandler from './main/ProxyHandler';
((window) => {
const snowboard = new Proxy(
new Snowboard(),
ProxyHandler,
);
// Cover all aliases
window.snowboard = snowboard;
window.Snowboard = snowboard;
window.SnowBoard = snowboard;
})(window);

View File

@@ -0,0 +1,9 @@
import AttributeRequest from './ajax/handlers/AttributeRequest';
if (window.Snowboard === undefined) {
throw new Error('Snowboard must be loaded in order to use the HTML data attribute AJAX request feature.');
}
((Snowboard) => {
Snowboard.addPlugin('attributeRequest', AttributeRequest);
})(window.Snowboard);

View File

@@ -0,0 +1,25 @@
import Flash from './extras/Flash';
import FlashListener from './extras/FlashListener';
import FormValidation from './extras/FormValidation';
import Transition from './extras/Transition';
import AttachLoading from './extras/AttachLoading';
import StripeLoader from './extras/StripeLoader';
import StylesheetLoader from './extras/StylesheetLoader';
import AssetLoader from './extras/AssetLoader';
import DataConfig from './extras/DataConfig';
if (window.Snowboard === undefined) {
throw new Error('Snowboard must be loaded in order to use the extra plugins.');
}
((Snowboard) => {
Snowboard.addPlugin('assetLoader', AssetLoader);
Snowboard.addPlugin('dataConfig', DataConfig);
Snowboard.addPlugin('extrasStyles', StylesheetLoader);
Snowboard.addPlugin('transition', Transition);
Snowboard.addPlugin('flash', Flash);
Snowboard.addPlugin('flashListener', FlashListener);
Snowboard.addPlugin('formValidation', FormValidation);
Snowboard.addPlugin('attachLoading', AttachLoading);
Snowboard.addPlugin('stripeLoader', StripeLoader);
})(window.Snowboard);

View File

@@ -0,0 +1,9 @@
import Request from './ajax/Request';
if (window.Snowboard === undefined) {
throw new Error('Snowboard must be loaded in order to use the Javascript AJAX request feature.');
}
((Snowboard) => {
Snowboard.addPlugin('request', Request);
})(window.Snowboard);

View File

@@ -0,0 +1,134 @@
import BaseCookie from 'js-cookie';
import Singleton from '../abstracts/Singleton';
/**
* Cookie utility.
*
* This utility is a thin wrapper around the "js-cookie" library.
*
* @see https://github.com/js-cookie/js-cookie
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Cookie extends Singleton {
construct() {
this.defaults = {
expires: null,
path: '/',
domain: null,
secure: false,
sameSite: 'Lax',
};
}
/**
* Set the default cookie parameters for all subsequent "set" and "remove" calls.
*
* @param {Object} options
*/
setDefaults(options) {
if (typeof options !== 'object') {
throw new Error('Cookie defaults must be provided as an object');
}
Object.entries(options).forEach((entry) => {
const [key, value] = entry;
if (this.defaults[key] !== undefined) {
this.defaults[key] = value;
}
});
}
/**
* Get the current default cookie parameters.
*
* @returns {Object}
*/
getDefaults() {
const defaults = {};
Object.entries(this.defaults).forEach((entry) => {
const [key, value] = entry;
if (this.defaults[key] !== null) {
defaults[key] = value;
}
});
return defaults;
}
/**
* Get a cookie by name.
*
* If `name` is undefined, returns all cookies as an Object.
*
* @param {String} name
* @returns {Object|String}
*/
get(name) {
if (name === undefined) {
const cookies = BaseCookie.get();
Object.entries(cookies).forEach((entry) => {
const [cookieName, cookieValue] = entry;
this.snowboard.globalEvent('cookie.get', cookieName, cookieValue, (newValue) => {
cookies[cookieName] = newValue;
});
});
return cookies;
}
let value = BaseCookie.get(name);
// Allow plugins to override the gotten value
this.snowboard.globalEvent('cookie.get', name, value, (newValue) => {
value = newValue;
});
return value;
}
/**
* Set a cookie by name.
*
* You can specify additional cookie parameters through the "options" parameter.
*
* @param {String} name
* @param {String} value
* @param {Object} options
* @returns {String}
*/
set(name, value, options) {
let saveValue = value;
// Allow plugins to override the value to save
this.snowboard.globalEvent('cookie.set', name, value, (newValue) => {
saveValue = newValue;
});
return BaseCookie.set(name, saveValue, {
...this.getDefaults(),
...options,
});
}
/**
* Remove a cookie by name.
*
* You can specify the additional cookie parameters via the "options" parameter.
*
* @param {String} name
* @param {Object} options
* @returns {void}
*/
remove(name, options) {
BaseCookie.remove(name, {
...this.getDefaults(),
...options,
});
}
}

View File

@@ -0,0 +1,395 @@
import Singleton from '../abstracts/Singleton';
/**
* JSON Parser utility.
*
* This utility parses JSON-like data that does not strictly meet the JSON specifications in order to simplify development.
* It is a safe replacement for JSON.parse(JSON.stringify(eval("({" + value + "})"))) that does not require the use of eval()
*
* @author Ayumi Hamasaki
* @author Ben Thomson <git@alfreido.com>
* @see https://github.com/octobercms/october/pull/4527
*/
export default class JsonParser extends Singleton {
construct() {
// Add to global function for backwards compatibility
window.wnJSON = (json) => this.parse(json);
window.ocJSON = window.wnJSON;
}
parse(str) {
const jsonString = this.parseString(str);
return JSON.parse(jsonString);
}
parseString(value) {
let str = value.trim();
if (!str.length) {
throw new Error('Broken JSON object.');
}
let result = '';
let type = null;
let key = null;
let body = '';
/*
* the mistake ','
*/
while (str && str[0] === ',') {
str = str.substr(1);
}
/*
* string
*/
if (str[0] === '"' || str[0] === '\'') {
if (str[str.length - 1] !== str[0]) {
throw new Error('Invalid string JSON object.');
}
body = '"';
for (let i = 1; i < str.length; i += 1) {
if (str[i] === '\\') {
if (str[i + 1] === '\'') {
body += str[i + 1];
} else {
body += str[i];
body += str[i + 1];
}
i += 1;
} else if (str[i] === str[0]) {
body += '"';
return body;
} else if (str[i] === '"') {
body += '\\"';
} else {
body += str[i];
}
}
throw new Error('Invalid string JSON object.');
}
/*
* boolean
*/
if (str === 'true' || str === 'false') {
return str;
}
/*
* null
*/
if (str === 'null') {
return 'null';
}
/*
* number
*/
const num = Number(str);
if (!Number.isNaN(num)) {
return num.toString();
}
/*
* object
*/
if (str[0] === '{') {
type = 'needKey';
key = null;
result = '{';
for (let i = 1; i < str.length; i += 1) {
if (this.isBlankChar(str[i])) {
/* eslint-disable-next-line */
continue;
}
if (type === 'needKey' && (str[i] === '"' || str[i] === '\'')) {
key = this.parseKey(str, i + 1, str[i]);
result += `"${key}"`;
i += key.length;
i += 1;
type = 'afterKey';
} else if (type === 'needKey' && this.canBeKeyHead(str[i])) {
key = this.parseKey(str, i);
result += '"';
result += key;
result += '"';
i += key.length - 1;
type = 'afterKey';
} else if (type === 'afterKey' && str[i] === ':') {
result += ':';
type = ':';
} else if (type === ':') {
body = this.getBody(str, i);
i = i + body.originLength - 1;
result += this.parseString(body.body);
type = 'afterBody';
} else if (type === 'afterBody' || type === 'needKey') {
let last = i;
while (str[last] === ',' || this.isBlankChar(str[last])) {
last += 1;
}
if (str[last] === '}' && last === str.length - 1) {
while (result[result.length - 1] === ',') {
result = result.substr(0, result.length - 1);
}
result += '}';
return result;
}
if (last !== i && result !== '{') {
result += ',';
type = 'needKey';
i = last - 1;
}
}
}
throw new Error(`Broken JSON object near ${result}`);
}
/*
* array
*/
if (str[0] === '[') {
result = '[';
type = 'needBody';
for (let i = 1; i < str.length; i += 1) {
if (str[i] === ' ' || str[i] === '\n' || str[i] === '\t') {
/* eslint-disable-next-line */
continue;
} else if (type === 'needBody') {
if (str[i] === ',') {
result += 'null,';
/* eslint-disable-next-line */
continue;
}
if (str[i] === ']' && i === str.length - 1) {
if (result[result.length - 1] === ',') {
result = result.substr(0, result.length - 1);
}
result += ']';
return result;
}
body = this.getBody(str, i);
i = i + body.originLength - 1;
result += this.parseString(body.body);
type = 'afterBody';
} else if (type === 'afterBody') {
if (str[i] === ',') {
result += ',';
type = 'needBody';
// deal with mistake ","
while (str[i + 1] === ',' || this.isBlankChar(str[i + 1])) {
if (str[i + 1] === ',') {
result += 'null,';
}
i += 1;
}
} else if (str[i] === ']' && i === str.length - 1) {
result += ']';
return result;
}
}
}
throw new Error(`Broken JSON array near ${result}`);
}
return '';
}
getBody(str, pos) {
let body = '';
// parse string body
if (str[pos] === '"' || str[pos] === '\'') {
body = str[pos];
for (let i = pos + 1; i < str.length; i += 1) {
if (str[i] === '\\') {
body += str[i];
if (i + 1 < str.length) {
body += str[i + 1];
}
i += 1;
} else if (str[i] === str[pos]) {
body += str[pos];
return {
originLength: body.length,
body,
};
} else {
body += str[i];
}
}
throw new Error(`Broken JSON string body near ${body}`);
}
// parse true / false
if (str[pos] === 't') {
if (str.indexOf('true', pos) === pos) {
return {
originLength: 'true'.length,
body: 'true',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
if (str[pos] === 'f') {
if (str.indexOf('f', pos) === pos) {
return {
originLength: 'false'.length,
body: 'false',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
// parse null
if (str[pos] === 'n') {
if (str.indexOf('null', pos) === pos) {
return {
originLength: 'null'.length,
body: 'null',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
// parse number
if (str[pos] === '-' || str[pos] === '+' || str[pos] === '.' || (str[pos] >= '0' && str[pos] <= '9')) {
body = '';
for (let i = pos; i < str.length; i += 1) {
if (str[i] === '-' || str[i] === '+' || str[i] === '.' || (str[i] >= '0' && str[i] <= '9')) {
body += str[i];
} else {
return {
originLength: body.length,
body,
};
}
}
throw new Error(`Broken JSON number body near ${body}`);
}
// parse object
if (str[pos] === '{' || str[pos] === '[') {
const stack = [
str[pos],
];
body = str[pos];
for (let i = pos + 1; i < str.length; i += 1) {
body += str[i];
if (str[i] === '\\') {
if (i + 1 < str.length) {
body += str[i + 1];
}
i += 1;
} else if (str[i] === '"') {
if (stack[stack.length - 1] === '"') {
stack.pop();
} else if (stack[stack.length - 1] !== '\'') {
stack.push(str[i]);
}
} else if (str[i] === '\'') {
if (stack[stack.length - 1] === '\'') {
stack.pop();
} else if (stack[stack.length - 1] !== '"') {
stack.push(str[i]);
}
} else if (stack[stack.length - 1] !== '"' && stack[stack.length - 1] !== '\'') {
if (str[i] === '{') {
stack.push('{');
} else if (str[i] === '}') {
if (stack[stack.length - 1] === '{') {
stack.pop();
} else {
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
} else if (str[i] === '[') {
stack.push('[');
} else if (str[i] === ']') {
if (stack[stack.length - 1] === '[') {
stack.pop();
} else {
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
}
}
if (!stack.length) {
return {
originLength: i - pos,
body,
};
}
}
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
throw new Error(`Broken JSON body near ${str.substr((pos - 5 >= 0) ? pos - 5 : 0, 50)}`);
}
parseKey(str, pos, quote) {
let key = '';
for (let i = pos; i < str.length; i += 1) {
if (quote && quote === str[i]) {
return key;
}
if (!quote && (str[i] === ' ' || str[i] === ':')) {
return key;
}
key += str[i];
if (str[i] === '\\' && i + 1 < str.length) {
key += str[i + 1];
i += 1;
}
}
throw new Error(`Broken JSON syntax near ${key}`);
}
canBeKeyHead(ch) {
if (ch[0] === '\\') {
return false;
}
if ((ch[0] >= 'a' && ch[0] <= 'z') || (ch[0] >= 'A' && ch[0] <= 'Z') || ch[0] === '_') {
return true;
}
if (ch[0] >= '0' && ch[0] <= '9') {
return true;
}
if (ch[0] === '$') {
return true;
}
if (ch.charCodeAt(0) > 255) {
return true;
}
return false;
}
isBlankChar(ch) {
return ch === ' ' || ch === '\n' || ch === '\t';
}
}

View File

@@ -0,0 +1,64 @@
import Singleton from '../abstracts/Singleton';
/**
* Sanitizer utility.
*
* Client-side HTML sanitizer designed mostly to prevent self-XSS attacks.
* The sanitizer utility will strip all attributes that start with `on` (usually JS event handlers as attributes, i.e. `onload` or `onerror`) or contain the `javascript:` pseudo protocol in their values.
*
* @author Ben Thomson <git@alfreido.com>
*/
export default class Sanitizer extends Singleton {
construct() {
// Add to global function for backwards compatibility
window.wnSanitize = (html) => this.sanitize(html);
window.ocSanitize = window.wnSanitize;
}
sanitize(html, bodyOnly) {
const parser = new DOMParser();
const dom = parser.parseFromString(html, 'text/html');
const returnBodyOnly = (bodyOnly !== undefined && typeof bodyOnly === 'boolean')
? bodyOnly
: true;
this.sanitizeNode(dom.getRootNode());
return (returnBodyOnly) ? dom.body.innerHTML : dom.innerHTML;
}
sanitizeNode(node) {
if (node.tagName === 'SCRIPT') {
node.remove();
return;
}
this.trimAttributes(node);
const children = Array.from(node.children);
children.forEach((child) => {
this.sanitizeNode(child);
});
}
trimAttributes(node) {
if (!node.attributes) {
return;
}
for (let i = 0; i < node.attributes.length; i += 1) {
const attrName = node.attributes.item(i).name;
const attrValue = node.attributes.item(i).value;
/*
* remove attributes where the names start with "on" (for example: onload, onerror...)
* remove attributes where the value starts with the "javascript:" pseudo protocol (for example href="javascript:alert(1)")
*/
/* eslint-disable-next-line */
if (attrName.indexOf('on') === 0 || attrValue.indexOf('javascript:') === 0) {
node.removeAttribute(attrName);
}
}
}
}

View File

@@ -0,0 +1,165 @@
import Singleton from '../abstracts/Singleton';
/**
* URL utility.
*
* This utility provides URL functions.
*
* @copyright 2022 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Url extends Singleton {
construct() {
this.foundBaseUrl = null;
this.foundAssetUrl = null;
this.baseUrl();
this.assetUrl();
}
/**
* Gets a URL based on a relative path.
*
* If an absolute URL is provided, it will be returned unchanged.
*
* @param {string} url
* @returns {string}
*/
to(url) {
const urlRegex = /^(?:[^:]+:\/\/)[-a-z0-9@:%._+~#=]{1,256}\b([-a-z0-9()@:%_+.~#?&//=]*)/i;
if (url.match(urlRegex)) {
return url;
}
const theUrl = url.replace(/^\/+/, '');
return `${this.baseUrl()}${theUrl}`;
}
/**
* Gets an Asset URL based on a relative path.
*
* If an absolute URL is provided, it will be returned unchanged.
*
* @param {string} url
* @returns {string}
*/
asset(url) {
const urlRegex = /^(?:[^:]+:\/\/)[-a-z0-9@:%._+~#=]{1,256}\b([-a-z0-9()@:%_+.~#?&//=]*)/i;
if (url.match(urlRegex)) {
return url;
}
const theUrl = url.replace(/^\/+/, '');
return `${this.assetUrl()}${theUrl}`;
}
/**
* Helper method to get the base URL of this install.
*
* This determines the base URL from three sources, in order:
* - If Snowboard is loaded via the `{% snowboard %}` tag, it will retrieve the base URL that
* is automatically included there.
* - If a `<base>` tag is available, it will use the URL specified in the base tag.
* - Finally, it will take a guess from the current location. This will likely not work for sites
* that reside in subdirectories.
*
* The base URL will always contain a trailing backslash.
*
* @returns {string}
*/
baseUrl() {
if (this.foundBaseUrl !== null) {
return this.foundBaseUrl;
}
if (document.querySelector('script[data-module="snowboard-base"]') !== null) {
this.foundBaseUrl = this.validateBaseUrl(document.querySelector('script[data-module="snowboard-base"]').dataset.baseUrl);
return this.foundBaseUrl;
}
if (document.querySelector('base') !== null) {
this.foundBaseUrl = this.validateBaseUrl(document.querySelector('base').getAttribute('href'));
return this.foundBaseUrl;
}
const urlParts = [
window.location.protocol,
'//',
window.location.host,
'/',
];
this.foundBaseUrl = urlParts.join('');
return this.foundBaseUrl;
}
/**
* Helper method to get the asset URL of this install.
*
* This determines the base URL from three sources, in order:
* - If Snowboard is loaded via the `{% snowboard %}` tag, it will retrieve the asset URL that
* is automatically included there.
* - If a `<link rel="asset_url" href="https://example.com">` tag is available, it will use the URL specified in the link tag.
* - Finally, it will take a guess from the current location. This will likely not work for sites
* that reside in subdirectories.
*
* The asset URL will always contain a trailing backslash.
*
* @returns {string}
*/
assetUrl() {
if (this.foundAssetUrl !== null) {
return this.foundAssetUrl;
}
if (document.querySelector('script[data-module="snowboard-base"]') !== null) {
this.foundAssetUrl = this.validateBaseUrl(document.querySelector('script[data-module="snowboard-base"]').dataset.assetUrl);
return this.foundAssetUrl;
}
if (document.querySelector('link[rel="asset_url"]') !== null) {
this.foundAssetUrl = this.validateBaseUrl(document.querySelector('link[rel="asset_url"]').getAttribute('href'));
return this.foundAssetUrl;
}
const urlParts = [
window.location.protocol,
'//',
window.location.host,
'/',
];
this.foundAssetUrl = urlParts.join('');
return this.foundAssetUrl;
}
/**
* Validates the base URL, ensuring it is a HTTP/HTTPs URL.
*
* If the Snowboard script or <base> tag on the page use a different type of URL, this will fail with
* an error.
*
* @param {string} url
* @returns {string}
*/
validateBaseUrl(url) {
const urlRegex = /^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/i;
const urlParts = urlRegex.exec(url);
const protocol = urlParts[2];
const domain = urlParts[4];
if (protocol && ['http', 'https'].indexOf(protocol.toLowerCase()) === -1) {
throw new Error('Invalid base URL detected');
}
if (!domain) {
throw new Error('Invalid base URL detected');
}
return (url.substr(-1) === '/')
? url
: `${url}/`;
}
}