@riotjs/route
Version:
Riot.js isomorphic router
1,436 lines (1,303 loc) • 47.8 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('riot')) :
typeof define === 'function' && define.amd ? define(['exports', 'riot'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.route = {}, global.riot));
})(this, (function (exports, riot) { 'use strict';
/**
* Tokenize input string.
*/
function lexer(str) {
var tokens = [];
var i = 0;
while (i < str.length) {
var char = str[i];
if (char === "*" || char === "+" || char === "?") {
tokens.push({ type: "MODIFIER", index: i, value: str[i++] });
continue;
}
if (char === "\\") {
tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] });
continue;
}
if (char === "{") {
tokens.push({ type: "OPEN", index: i, value: str[i++] });
continue;
}
if (char === "}") {
tokens.push({ type: "CLOSE", index: i, value: str[i++] });
continue;
}
if (char === ":") {
var name = "";
var j = i + 1;
while (j < str.length) {
var code = str.charCodeAt(j);
if (
// `0-9`
(code >= 48 && code <= 57) ||
// `A-Z`
(code >= 65 && code <= 90) ||
// `a-z`
(code >= 97 && code <= 122) ||
// `_`
code === 95) {
name += str[j++];
continue;
}
break;
}
if (!name)
throw new TypeError("Missing parameter name at ".concat(i));
tokens.push({ type: "NAME", index: i, value: name });
i = j;
continue;
}
if (char === "(") {
var count = 1;
var pattern = "";
var j = i + 1;
if (str[j] === "?") {
throw new TypeError("Pattern cannot start with \"?\" at ".concat(j));
}
while (j < str.length) {
if (str[j] === "\\") {
pattern += str[j++] + str[j++];
continue;
}
if (str[j] === ")") {
count--;
if (count === 0) {
j++;
break;
}
}
else if (str[j] === "(") {
count++;
if (str[j + 1] !== "?") {
throw new TypeError("Capturing groups are not allowed at ".concat(j));
}
}
pattern += str[j++];
}
if (count)
throw new TypeError("Unbalanced pattern at ".concat(i));
if (!pattern)
throw new TypeError("Missing pattern at ".concat(i));
tokens.push({ type: "PATTERN", index: i, value: pattern });
i = j;
continue;
}
tokens.push({ type: "CHAR", index: i, value: str[i++] });
}
tokens.push({ type: "END", index: i, value: "" });
return tokens;
}
/**
* Parse a string for the raw tokens.
*/
function parse(str, options) {
if (options === void 0) { options = {}; }
var tokens = lexer(str);
var _a = options.prefixes, prefixes = _a === void 0 ? "./" : _a;
var defaultPattern = "[^".concat(escapeString(options.delimiter || "/#?"), "]+?");
var result = [];
var key = 0;
var i = 0;
var path = "";
var tryConsume = function (type) {
if (i < tokens.length && tokens[i].type === type)
return tokens[i++].value;
};
var mustConsume = function (type) {
var value = tryConsume(type);
if (value !== undefined)
return value;
var _a = tokens[i], nextType = _a.type, index = _a.index;
throw new TypeError("Unexpected ".concat(nextType, " at ").concat(index, ", expected ").concat(type));
};
var consumeText = function () {
var result = "";
var value;
while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) {
result += value;
}
return result;
};
while (i < tokens.length) {
var char = tryConsume("CHAR");
var name = tryConsume("NAME");
var pattern = tryConsume("PATTERN");
if (name || pattern) {
var prefix = char || "";
if (prefixes.indexOf(prefix) === -1) {
path += prefix;
prefix = "";
}
if (path) {
result.push(path);
path = "";
}
result.push({
name: name || key++,
prefix: prefix,
suffix: "",
pattern: pattern || defaultPattern,
modifier: tryConsume("MODIFIER") || "",
});
continue;
}
var value = char || tryConsume("ESCAPED_CHAR");
if (value) {
path += value;
continue;
}
if (path) {
result.push(path);
path = "";
}
var open = tryConsume("OPEN");
if (open) {
var prefix = consumeText();
var name_1 = tryConsume("NAME") || "";
var pattern_1 = tryConsume("PATTERN") || "";
var suffix = consumeText();
mustConsume("CLOSE");
result.push({
name: name_1 || (pattern_1 ? key++ : ""),
pattern: name_1 && !pattern_1 ? defaultPattern : pattern_1,
prefix: prefix,
suffix: suffix,
modifier: tryConsume("MODIFIER") || "",
});
continue;
}
mustConsume("END");
}
return result;
}
/**
* Compile a string to a template function for the path.
*/
function compile(str, options) {
return tokensToFunction(parse(str, options), options);
}
/**
* Expose a method for transforming tokens into the path function.
*/
function tokensToFunction(tokens, options) {
if (options === void 0) { options = {}; }
var reFlags = flags(options);
var _a = options.encode, encode = _a === void 0 ? function (x) { return x; } : _a, _b = options.validate, validate = _b === void 0 ? true : _b;
// Compile all the tokens into regexps.
var matches = tokens.map(function (token) {
if (typeof token === "object") {
return new RegExp("^(?:".concat(token.pattern, ")$"), reFlags);
}
});
return function (data) {
var path = "";
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
if (typeof token === "string") {
path += token;
continue;
}
var value = data ? data[token.name] : undefined;
var optional = token.modifier === "?" || token.modifier === "*";
var repeat = token.modifier === "*" || token.modifier === "+";
if (Array.isArray(value)) {
if (!repeat) {
throw new TypeError("Expected \"".concat(token.name, "\" to not repeat, but got an array"));
}
if (value.length === 0) {
if (optional)
continue;
throw new TypeError("Expected \"".concat(token.name, "\" to not be empty"));
}
for (var j = 0; j < value.length; j++) {
var segment = encode(value[j], token);
if (validate && !matches[i].test(segment)) {
throw new TypeError("Expected all \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\""));
}
path += token.prefix + segment + token.suffix;
}
continue;
}
if (typeof value === "string" || typeof value === "number") {
var segment = encode(String(value), token);
if (validate && !matches[i].test(segment)) {
throw new TypeError("Expected \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\""));
}
path += token.prefix + segment + token.suffix;
continue;
}
if (optional)
continue;
var typeOfMessage = repeat ? "an array" : "a string";
throw new TypeError("Expected \"".concat(token.name, "\" to be ").concat(typeOfMessage));
}
return path;
};
}
/**
* Escape a regular expression string.
*/
function escapeString(str) {
return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
}
/**
* Get the flags for a regexp from the options.
*/
function flags(options) {
return options && options.sensitive ? "" : "i";
}
/**
* Pull out keys from a regexp.
*/
function regexpToRegexp(path, keys) {
if (!keys)
return path;
var groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g;
var index = 0;
var execResult = groupsRegex.exec(path.source);
while (execResult) {
keys.push({
// Use parenthesized substring match if available, index otherwise
name: execResult[1] || index++,
prefix: "",
suffix: "",
modifier: "",
pattern: "",
});
execResult = groupsRegex.exec(path.source);
}
return path;
}
/**
* Transform an array into a regexp.
*/
function arrayToRegexp(paths, keys, options) {
var parts = paths.map(function (path) { return pathToRegexp(path, keys, options).source; });
return new RegExp("(?:".concat(parts.join("|"), ")"), flags(options));
}
/**
* Create a path regexp from string input.
*/
function stringToRegexp(path, keys, options) {
return tokensToRegexp(parse(path, options), keys, options);
}
/**
* Expose a function for taking tokens and returning a RegExp.
*/
function tokensToRegexp(tokens, keys, options) {
if (options === void 0) { options = {}; }
var _a = options.strict, strict = _a === void 0 ? false : _a, _b = options.start, start = _b === void 0 ? true : _b, _c = options.end, end = _c === void 0 ? true : _c, _d = options.encode, encode = _d === void 0 ? function (x) { return x; } : _d, _e = options.delimiter, delimiter = _e === void 0 ? "/#?" : _e, _f = options.endsWith, endsWith = _f === void 0 ? "" : _f;
var endsWithRe = "[".concat(escapeString(endsWith), "]|$");
var delimiterRe = "[".concat(escapeString(delimiter), "]");
var route = start ? "^" : "";
// Iterate over the tokens and create our regexp string.
for (var _i = 0, tokens_1 = tokens; _i < tokens_1.length; _i++) {
var token = tokens_1[_i];
if (typeof token === "string") {
route += escapeString(encode(token));
}
else {
var prefix = escapeString(encode(token.prefix));
var suffix = escapeString(encode(token.suffix));
if (token.pattern) {
if (keys)
keys.push(token);
if (prefix || suffix) {
if (token.modifier === "+" || token.modifier === "*") {
var mod = token.modifier === "*" ? "?" : "";
route += "(?:".concat(prefix, "((?:").concat(token.pattern, ")(?:").concat(suffix).concat(prefix, "(?:").concat(token.pattern, "))*)").concat(suffix, ")").concat(mod);
}
else {
route += "(?:".concat(prefix, "(").concat(token.pattern, ")").concat(suffix, ")").concat(token.modifier);
}
}
else {
if (token.modifier === "+" || token.modifier === "*") {
route += "((?:".concat(token.pattern, ")").concat(token.modifier, ")");
}
else {
route += "(".concat(token.pattern, ")").concat(token.modifier);
}
}
}
else {
route += "(?:".concat(prefix).concat(suffix, ")").concat(token.modifier);
}
}
}
if (end) {
if (!strict)
route += "".concat(delimiterRe, "?");
route += !options.endsWith ? "$" : "(?=".concat(endsWithRe, ")");
}
else {
var endToken = tokens[tokens.length - 1];
var isEndDelimited = typeof endToken === "string"
? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1
: endToken === undefined;
if (!strict) {
route += "(?:".concat(delimiterRe, "(?=").concat(endsWithRe, "))?");
}
if (!isEndDelimited) {
route += "(?=".concat(delimiterRe, "|").concat(endsWithRe, ")");
}
}
return new RegExp(route, flags(options));
}
/**
* Normalize the given path string, returning a regular expression.
*
* An empty array can be passed in for the keys, which will hold the
* placeholder key descriptions. For example, using `/user/:id`, `keys` will
* contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`.
*/
function pathToRegexp(path, keys, options) {
if (path instanceof RegExp)
return regexpToRegexp(path, keys);
if (Array.isArray(path))
return arrayToRegexp(path, keys, options);
return stringToRegexp(path, keys, options);
}
/**
* Cancel token
* @private
* @type { Symbol }
*/
const CANCEL = Symbol();
/**
* Helper that can be returned by ruit function to cancel the tasks chain
* @returns { Symbol } internal private constant
* @example
*
* ruit(
* 100,
* num => Math.random() * num
* num => num > 50 ? ruit.cancel() : num
* num => num - 2
* ).then(result => {
* console.log(result) // here we will get only number lower than 50
* })
*
*/
ruit.cancel = () => CANCEL;
/**
* The same as ruit() but with the arguments inverted from right to left
* @param { * } tasks - list of tasks to process sequentially
* @returns { Promise } a promise containing the result of the whole chain
* @example
*
* const curry = f => a => b => f(a, b)
* const add = (a, b) => a + b
*
* const addOne = curry(add)(1)
*
* const squareAsync = (num) => {
* return new Promise(r => {
* setTimeout(r, 500, num * 2)
* })
* }
*
* // a -> a + a -> a * 2
* // basically from right to left: 1 => 1 + 1 => 2 * 2
* ruit.compose(squareAsync, addOne, 1).then(result => console.log(result)) // 4
*/
ruit.compose = (...tasks) => ruit(...tasks.reverse());
/**
* Serialize a list of sync and async tasks from left to right
* @param { * } tasks - list of tasks to process sequentially
* @returns { Promise } a promise containing the result of the whole chain
* @example
*
* const curry = f => a => b => f(a, b)
* const add = (a, b) => a + b
*
* const addOne = curry(add)(1)
*
* const squareAsync = (num) => {
* return new Promise(r => {
* setTimeout(r, 500, num * 2)
* })
* }
*
* // a -> a + a -> a * 2
* // basically from left to right: 1 => 1 + 1 => 2 * 2
* ruit(1, addOne, squareAsync).then(result => console.log(result)) // 4
*/
function ruit(...tasks) {
return new Promise((resolve, reject) => {
return (function run(queue, result) {
if (!queue.length) return resolve(result)
const [task, ...rest] = queue;
const value = typeof task === 'function' ? task(result) : task;
const done = v => run(rest, v);
// check against nil values
if (value != null) {
if (value === CANCEL) return
if (value.then) return value.then(done, reject)
}
return Promise.resolve(done(value))
})(tasks)
})
}
// Store the erre the API methods to handle the plugins installation
const API_METHODS = new Set();
const UNSUBSCRIBE_SYMBOL = Symbol();
const UNSUBSCRIBE_METHOD = 'off';
const CANCEL_METHOD = 'cancel';
/**
* Factory function to create the stream generator
* @private
* @param {Set} modifiers - stream input modifiers
* @returns {Generator} the stream generator
*/
function createStream(modifiers) {
const stream = (function *stream() {
while (true) {
// get the initial stream value
const input = yield;
// run the input sequence
yield ruit(input, ...modifiers);
}
})();
// start the stream
stream.next();
return stream
}
/**
* Dispatch a value to several listeners
* @private
* @param {Set} callbacks - callbacks collection
* @param {*} value - anything
* @returns {Set} the callbacks received
*/
function dispatch(callbacks, value) {
callbacks.forEach(f => {
// unsubscribe the callback if erre.unsubscribe() will be returned
if (f(value) === UNSUBSCRIBE_SYMBOL) callbacks.delete(f);
});
return callbacks
}
/**
* Throw a panic error
* @param {string} message - error message
* @returns {Error} an error object
*/
function panic$1(message) {
throw new Error(message)
}
/**
* Install an erre plugin adding it to the API
* @param {string} name - plugin name
* @param {Function} fn - new erre API method
* @returns {Function} return the erre function
*/
erre.install = function(name, fn) {
if (!name || typeof name !== 'string')
panic$1('Please provide a name (as string) for your erre plugin');
if (!fn || typeof fn !== 'function')
panic$1('Please provide a function for your erre plugin');
if (API_METHODS.has(name)) {
panic$1(`The ${name} is already part of the erre API, please provide a different name`);
} else {
erre[name] = fn;
API_METHODS.add(name);
}
return erre
};
// alias for ruit canel to stop a stream chain
erre.install(CANCEL_METHOD, ruit.cancel);
// unsubscribe helper
erre.install(UNSUBSCRIBE_METHOD, () => UNSUBSCRIBE_SYMBOL);
/**
* Stream constuction function
* @param {...Function} fns - stream modifiers
* @returns {Object} erre instance
*/
function erre(...fns) {
const
[success, error, end, modifiers] = [new Set(), new Set(), new Set(), new Set(fns)],
generator = createStream(modifiers),
stream = Object.create(generator),
addToCollection = (collection) => (fn) => collection.add(fn) && stream,
deleteFromCollection = (collection) => (fn) => collection.delete(fn) ? stream
: panic$1('Couldn\'t remove handler passed by reference');
return Object.assign(stream, {
on: Object.freeze({
value: addToCollection(success),
error: addToCollection(error),
end: addToCollection(end)
}),
off: Object.freeze({
value: deleteFromCollection(success),
error: deleteFromCollection(error),
end: deleteFromCollection(end)
}),
connect: addToCollection(modifiers),
push(input) {
const { value, done } = stream.next(input);
// dispatch the stream events
if (!done) {
value.then(
res => dispatch(success, res),
err => dispatch(error, err)
);
}
return stream
},
end() {
// kill the stream
generator.return();
// dispatch the end event
dispatch(end)
// clean up all the collections
;[success, error, end, modifiers].forEach(el => el.clear());
return stream
},
fork() {
return erre(...modifiers)
},
next(input) {
// get the input and run eventually the promise
const result = generator.next(input);
// pause to the next iteration
generator.next();
return result
}
})
}
const isString = str => typeof str === 'string';
const parseURL = (...args) => new URL(...args);
/**
* Replace the base path from a path
* @param {string} path - router path string
* @returns {string} path cleaned up without the base
*/
const replaceBase = path => path.replace(defaults.base, '');
/**
* Try to match the current path or skip it
* @param {RegExp} pathRegExp - target path transformed by pathToRegexp
* @returns {string|Symbol} if the path match we return it otherwise we cancel the stream
*/
const matchOrSkip = pathRegExp => path => match(path, pathRegExp) ? path : erre.cancel();
/**
* Combine 2 streams connecting the events of dispatcherStream to the receiverStream
* @param {Stream} dispatcherStream - main stream dispatching events
* @param {Stream} receiverStream - sub stream receiving events from the dispatcher
* @returns {Stream} receiverStream
*/
const joinStreams = (dispatcherStream, receiverStream) => {
dispatcherStream.on.value(receiverStream.push);
receiverStream.on.end(() => {
dispatcherStream.off.value(receiverStream.push);
});
return receiverStream
};
/**
* Error handling function
* @param {Error} error - error to catch
* @returns {void}
*/
/* c8 ignore start */
const panic$2 = error => {
if (defaults.silentErrors) return
throw new Error(error)
};
/* c8 ignore stop */
// make sure that the router will always receive strings params
const filterStrings = str => isString(str) ? str : erre.cancel();
// create the streaming router
const router = erre(filterStrings).on.error(panic$2); // cast the values of this stream always to string
/**
* Merge the user options with the defaults
* @param {Object} options - custom user options
* @returns {Object} options object merged with defaults
*/
const mergeOptions = options => ({...defaults, ...options});
/* @type {object} general configuration object */
const defaults = {
base: 'https://localhost',
silentErrors: false,
// pathToRegexp options
sensitive: false,
strict: false,
end: true,
start: true,
delimiter: '/#?',
encode: undefined,
endsWith: undefined,
prefixes: './'
};
/**
* Configure the router options overriding the defaults
* @param {Object} options - custom user options to override
* @returns {Object} new defaults
*/
const configure = (options) => {
Object.entries(options).forEach(([key, value]) => {
if (Object.hasOwn(defaults, key)) defaults[key] = value;
});
return defaults
};
/* {@link https://github.com/pillarjs/path-to-regexp#usage} */
const toRegexp = (path, keys, options) => pathToRegexp(path, keys, mergeOptions(options));
/**
* Convert a router entry to a real path computing the url parameters
* @param {string} path - router path string
* @param {Object} params - named matched parameters
* @param {Object} options - pathToRegexp options object
* @returns {string} computed url string
*/
const toPath = (path, params, options) => compile(path, mergeOptions(options))(params);
/**
* Parse a string path generating an object containing
* @param {string} path - target path
* @param {RegExp} pathRegExp - path transformed to regexp via pathToRegexp
* @param {Object} options - object containing the base path
* @returns {URL} url object enhanced with the `match` attribute
*/
const toURL = (path, pathRegExp, options = {}) => {
const {base} = mergeOptions(options);
const [, ...params] = pathRegExp.exec(path);
const url = parseURL(path, base);
// extend the url object adding the matched params
url.params = params.reduce((acc, param, index) => {
const key = options.keys && options.keys[index];
if (key) acc[key.name] = param ? decodeURIComponent(param) : param;
return acc
}, {});
return url
};
/**
* Return true if a path will be matched
* @param {string} path - target path
* @param {RegExp} pathRegExp - path transformed to regexp via pathToRegexp
* @returns {boolean} true if the path matches the regexp
*/
const match = (path, pathRegExp) => pathRegExp.test(path);
/**
* Factory function to create an sequence of functions to pass to erre.js
* This function will be used in the erre stream
* @param {RegExp} pathRegExp - path transformed to regexp via pathToRegexp
* @param {Object} options - pathToRegexp options object
* @returns {Array} a functions array that will be used as stream pipe for erre.js
*/
const createURLStreamPipe = (pathRegExp, options) => [
decodeURI,
replaceBase,
matchOrSkip(pathRegExp),
path => toURL(path, pathRegExp, options)
];
/**
* Create a fork of the main router stream
* @param {string} path - route to match
* @param {Object} options - pathToRegexp options object
* @returns {Stream} new route stream
*/
function createRoute(path, options) {
const keys = [];
const pathRegExp = pathToRegexp(path, keys, options);
const URLStream = erre(...createURLStreamPipe(pathRegExp, {
...options,
keys
}));
return joinStreams(router, URLStream).on.error(panic$2)
}
const WINDOW_EVENTS = 'popstate';
const CLICK_EVENT = 'click';
const DOWNLOAD_LINK_ATTRIBUTE = 'download';
const HREF_LINK_ATTRIBUTE = 'href';
const TARGET_SELF_LINK_ATTRIBUTE = '_self';
const LINK_TAG_NAME = 'A';
const HASH = '#';
const SLASH = '/';
const PATH_ATTRIBUTE = 'path';
const RE_ORIGIN = /^.+?\/\/+[^/]+/;
/**
* Converts any DOM node/s to a loopable array
* @param { HTMLElement|NodeList } els - single html element or a node list
* @returns { Array } always a loopable object
*/
function domToArray(els) {
// can this object be already looped?
if (!Array.isArray(els)) {
// is it a node list?
if (
/^\[object (HTMLCollection|NodeList|Object)\]$/
.test(Object.prototype.toString.call(els))
&& typeof els.length === 'number'
)
return Array.from(els)
else
// if it's a single node
// it will be returned as "array" with one single entry
return [els]
}
// this object could be looped out of the box
return els
}
/**
* Simple helper to find DOM nodes returning them as array like loopable object
* @param { string|DOMNodeList } selector - either the query or the DOM nodes to arraify
* @param { HTMLElement } scope - context defining where the query will search for the DOM nodes
* @returns { Array } DOM nodes found as array
*/
function $(selector, scope) {
return domToArray(typeof selector === 'string' ?
(document).querySelectorAll(selector) :
selector
)
}
const getCurrentRoute = ((currentRoute) => {
// listen the route changes events to store the current route
router.on.value((r) => (currentRoute = r));
return () => {
return currentRoute
}
})(null);
/**
* Normalize the return values, in case of a single value we avoid to return an array
* @param { Array } values - list of values we want to return
* @returns { Array|string|boolean } either the whole list of values or the single one found
* @private
*/
const normalize = values => values.length === 1 ? values[0] : values;
/**
* Parse all the nodes received to get/remove/check their attributes
* @param { HTMLElement|NodeList|Array } els - DOM node/s to parse
* @param { string|Array } name - name or list of attributes
* @param { string } method - method that will be used to parse the attributes
* @returns { Array|string } result of the parsing in a list or a single value
* @private
*/
function parseNodes(els, name, method) {
const names = typeof name === 'string' ? [name] : name;
return normalize(domToArray(els).map(el => {
return normalize(names.map(n => el[method](n)))
}))
}
/**
* Get any attribute from a single or a list of DOM nodes
* @param { HTMLElement|NodeList|Array } els - DOM node/s to parse
* @param { string|Array } name - name or list of attributes to get
* @returns { Array|string } list of the attributes found
*
* @example
*
* import { get } from 'bianco.attr'
*
* const img = document.createElement('img')
*
* get(img, 'width') // => '200'
*
* // or also
* get(img, ['width', 'height']) // => ['200', '300']
*
* // or also
* get([img1, img2], ['width', 'height']) // => [['200', '300'], ['500', '200']]
*/
function get(els, name) {
return parseNodes(els, name, 'getAttribute')
}
/**
* Set any attribute on a single or a list of DOM nodes
* @param { HTMLElement|NodeList|Array } els - DOM node/s to parse
* @param { string|Array } name - name or list of attributes to detect
* @returns { boolean|Array } true or false or an array of boolean values
* @example
*
* import { has } from 'bianco.attr'
*
* has(img, 'width') // false
*
* // or also
* has(img, ['width', 'height']) // => [false, false]
*
* // or also
* has([img1, img2], ['width', 'height']) // => [[false, false], [false, false]]
*/
function has(els, name) {
return parseNodes(els, name, 'hasAttribute')
}
/**
* Convert a string from camel case to dash-case
* @param {string} string - probably a component tag name
* @returns {string} component name normalized
*/
/**
* Convert a string containing dashes to camel case
* @param {string} string - input string
* @returns {string} my-string -> myString
*/
function dashToCamelCase(string) {
return string.replace(/-(\w)/g, (_, c) => c.toUpperCase())
}
/**
* Check if a value is null or undefined
* @param {*} value - anything
* @returns {boolean} true only for the 'undefined' and 'null' types
*/
function isNil(value) {
return value === null || value === undefined
}
const getGlobal = () => getWindow() || global;
const getWindow = () => (typeof window === 'undefined' ? null : window);
const getDocument = () =>
typeof document === 'undefined' ? null : document;
const getHistory = () =>
typeof history === 'undefined' ? null : history;
const getLocation = () => {
const win = getWindow();
return win ? win.location : {}
};
const defer = (() => {
const globalScope = getGlobal();
return globalScope.requestAnimationFrame || globalScope.setTimeout
})();
const cancelDefer = (() => {
const globalScope = getGlobal();
return globalScope.cancelAnimationFrame || globalScope.clearTimeout
})();
const getAttribute = (attributes, name, context) => {
if (!attributes) return null
const normalizedAttributes = attributes.flatMap((attr) =>
isNil(attr.name)
? // add support for spread attributes https://github.com/riot/route/issues/178
Object.entries(attr.evaluate(context)).map(([key, value]) => ({
// evaluate each value of the spread attribute and store it into the array
name: key,
// create a nested evaluate function pointing to the original value of the spread object
evaluate: () => value,
}))
: attr,
);
return normalizedAttributes.find((a) => dashToCamelCase(a.name) === name)
};
const createDefaultSlot = (attributes = []) => {
const { template, bindingTypes, expressionTypes } = riot.__.DOMBindings;
return template(null, [
{
type: bindingTypes.SLOT,
name: 'default',
attributes: attributes.map((attr) => ({
...attr,
type: expressionTypes.ATTRIBUTE,
})),
},
])
};
// True if the selector string is valid
const isValidQuerySelectorString = (selector) =>
/^([a-zA-Z0-9-_*#.:[\]\s>+~()='"]|\\.)+$/.test(selector);
/**
* Similar to compose but performs from left-to-right function composition.<br/>
* {@link https://30secondsofcode.org/function#composeright see also}
* @param {...[function]} fns) - list of unary function
* @returns {*} result of the computation
*/
/**
* Performs right-to-left function composition.<br/>
* Use Array.prototype.reduce() to perform right-to-left function composition.<br/>
* The last (rightmost) function can accept one or more arguments; the remaining functions must be unary.<br/>
* {@link https://30secondsofcode.org/function#compose original source code}
* @param {...[function]} fns) - list of unary function
* @returns {*} result of the computation
*/
function compose(...fns) {
return fns.reduce((f, g) => (...args) => f(g(...args)))
}
const getInitialRouteValue = (pathToRegexp, path, options) => {
const route = compose(
...createURLStreamPipe(pathToRegexp, options).reverse(),
)(path);
return route.params ? route : null
};
const clearDOMBetweenNodes = (first, last, includeBoundaries) => {
const clear = (node) => {
if (!node || (node === last && !includeBoundaries)) return
const { nextSibling } = node;
node.remove();
clear(nextSibling);
};
clear(includeBoundaries ? first : first.nextSibling);
};
const routeHoc$1 = ({ slots, attributes }) => {
const placeholders = {
before: document.createTextNode(''),
after: document.createTextNode(''),
};
return {
mount(el, context) {
// create the component state
const currentRoute = getCurrentRoute();
const path =
getAttribute(attributes, PATH_ATTRIBUTE, context)?.evaluate(context) ||
get(el, PATH_ATTRIBUTE);
const pathToRegexp = toRegexp(path, []);
const state = {
pathToRegexp,
route:
currentRoute && match(currentRoute, pathToRegexp)
? getInitialRouteValue(pathToRegexp, currentRoute, {})
: null,
};
this.el = el;
this.slot = createDefaultSlot([
{
isBoolean: false,
name: 'route',
evaluate: () => this.state.route,
},
]);
this.context = context;
this.state = state;
// set the route listeners
this.boundOnBeforeRoute = this.onBeforeRoute.bind(this);
this.boundOnRoute = this.onRoute.bind(this);
router.on.value(this.boundOnBeforeRoute);
this.stream = createRoute(path).on.value(this.boundOnRoute);
// update the DOM
el.replaceWith(placeholders.before);
placeholders.before.parentNode.insertBefore(
placeholders.after,
placeholders.before.nextSibling,
);
if (state.route) this.mountSlot();
},
update(context) {
this.context = context;
if (this.state.route) this.slot.update({}, context);
},
mountSlot() {
const { route } = this.state;
// insert the route root element after the before placeholder
placeholders.before.parentNode.insertBefore(
this.el,
placeholders.before.nextSibling,
);
this.callLifecycleProperty('onBeforeMount', route);
this.slot.mount(
this.el,
{
slots,
},
this.context,
);
this.callLifecycleProperty('onMounted', route);
},
clearDOM(includeBoundaries) {
// remove all the DOM nodes between the placeholders
clearDOMBetweenNodes(
placeholders.before,
placeholders.after,
includeBoundaries,
);
},
unmount() {
router.off.value(this.boundOnBeforeRoute);
this.slot.unmount({}, this.context, true);
this.clearDOM(true);
this.stream.end();
},
onBeforeRoute(path) {
const { route } = this.state;
// this component was not mounted or the current path matches
// we don't need to unmount this component
if (!route || match(path, this.state.pathToRegexp)) return
this.callLifecycleProperty('onBeforeUnmount', route);
this.slot.unmount({}, this.context, true);
this.clearDOM(false);
this.state.route = null;
this.callLifecycleProperty('onUnmounted', route);
},
onRoute(route) {
const prevRoute = this.state.route;
this.state.route = route;
// if this route component was already mounted we need to update it
if (prevRoute) {
this.callLifecycleProperty('onBeforeUpdate', route);
this.slot.update({}, this.context);
this.callLifecycleProperty('onUpdated', route);
}
// this route component was never mounted, so we need to create its DOM
else this.mountSlot();
// emulate the default browser anchor links behaviour
if (route.hash && isValidQuerySelectorString(route.hash))
$(route.hash)?.[0].scrollIntoView();
},
callLifecycleProperty(method, ...params) {
const attr = getAttribute(attributes, method, this.context);
if (attr) attr.evaluate(this.context)(...params);
},
}
};
var routeHoc = {
css: null,
exports: riot.pure(
routeHoc$1
),
template: null,
name: 'route-hoc'
};
const normalizeInitialSlash = (str) =>
str[0] === SLASH ? str : `${SLASH}${str}`;
const removeTrailingSlash = (str) =>
str[str.length - 1] === SLASH ? str.substr(0, str.length - 1) : str;
const normalizeBase = (base) => {
const win = getWindow();
const loc = win.location;
const root = loc ? `${loc.protocol}//${loc.host}` : '';
const { pathname } = loc ? loc : {};
switch (true) {
// pure root url + pathname
case Boolean(base) === false:
return removeTrailingSlash(`${root}${pathname || ''}`)
// full path base
case /(www|http(s)?:)/.test(base):
return base
// hash navigation
case base[0] === HASH:
return `${root}${pathname && pathname !== SLASH ? pathname : ''}${base}`
// root url with trailing slash
case base === SLASH:
return removeTrailingSlash(root)
// custom pathname
default:
return removeTrailingSlash(`${root}${normalizeInitialSlash(base)}`)
}
};
function setBase(base) {
configure({ base: normalizeBase(base) });
}
/**
* Throw an error with a descriptive message
* @param { string } message - error message
* @param { string } cause - optional error cause object
* @returns { undefined } hoppla... at this point the program should stop working
*/
function panic(message, cause) {
throw new Error(message, { cause })
}
/**
* Split a string into several items separed by spaces
* @param { string } l - events list
* @returns { Array } all the events detected
* @private
*/
const split = l => l.split(/\s/);
/**
* Set a listener for all the events received separated by spaces
* @param { HTMLElement|NodeList|Array } els - DOM node/s where the listeners will be bound
* @param { string } evList - list of events we want to bind or unbind space separated
* @param { Function } cb - listeners callback
* @param { string } method - either 'addEventListener' or 'removeEventListener'
* @param { Object } options - event options (capture, once and passive)
* @returns { undefined }
* @private
*/
function manageEvents(els, evList, cb, method, options) {
els = domToArray(els);
split(evList).forEach((e) => {
els.forEach(el => el[method](e, cb, options || false));
});
}
/**
* Set a listener for all the events received separated by spaces
* @param { HTMLElement|Array } els - DOM node/s where the listeners will be bound
* @param { string } evList - list of events we want to bind space separated
* @param { Function } cb - listeners callback
* @param { Object } options - event options (capture, once and passive)
* @returns { HTMLElement|NodeList|Array } DOM node/s and first argument of the function
*/
function add(els, evList, cb, options) {
manageEvents(els, evList, cb, 'addEventListener', options);
return els
}
/**
* Remove all the listeners for the events received separated by spaces
* @param { HTMLElement|Array } els - DOM node/s where the events will be unbind
* @param { string } evList - list of events we want unbind space separated
* @param { Function } cb - listeners callback
* @param { Object } options - event options (capture, once and passive)
* @returns { HTMLElement|NodeList|Array } DOM node/s and first argument of the function
*/
function remove(els, evList, cb, options) {
manageEvents(els, evList, cb, 'removeEventListener', options);
return els
}
const onWindowEvent = () =>
router.push(normalizePath(String(getLocation().href)));
const onRouterPush = (path) => {
const url = path.includes(defaults.base) ? path : defaults.base + path;
const loc = getLocation();
const hist = getHistory();
const doc = getDocument();
// update the browser history only if it's necessary
if (hist && url !== loc.href) {
hist.pushState(null, doc.title, url);
}
};
const getLinkElement = (node) =>
node && !isLinkNode(node) ? getLinkElement(node.parentNode) : node;
const isLinkNode = (node) => node.nodeName === LINK_TAG_NAME;
const isCrossOriginLink = (path) =>
path.indexOf(getLocation().href.match(RE_ORIGIN)[0]) === -1;
const isTargetSelfLink = (el) =>
el.target && el.target !== TARGET_SELF_LINK_ATTRIBUTE;
const isEventForbidden = (event) =>
(event.which && event.which !== 1) || // not left click
event.metaKey ||
event.ctrlKey ||
event.shiftKey || // or meta keys
event.defaultPrevented; // or default prevented
const isForbiddenLink = (el) =>
!el ||
!isLinkNode(el) || // not A tag
has(el, DOWNLOAD_LINK_ATTRIBUTE) || // has download attr
!has(el, HREF_LINK_ATTRIBUTE) || // has no href attr
isTargetSelfLink(el) ||
isCrossOriginLink(el.href);
const normalizePath = (path) => path.replace(defaults.base, '');
const isInBase = (path) => !defaults.base || path.includes(defaults.base);
/**
* Callback called anytime something will be clicked on the page
* @param {Event} event - click event
* @returns {undefined} void method
*/
const onClick = (event) => {
if (isEventForbidden(event)) return
const el = getLinkElement(event.target);
if (isForbiddenLink(el) || !isInBase(el.href)) return
event.preventDefault();
router.push(normalizePath(el.href));
};
/**
* Link the rawth router to the DOM events
* @param { HTMLElement } container - DOM node where the links are located
* @returns {Function} teardown function
*/
function initDomListeners(container) {
const win = getWindow();
const root = container || getDocument();
if (win) {
add(win, WINDOW_EVENTS, onWindowEvent);
add(root, CLICK_EVENT, onClick);
}
router.on.value(onRouterPush);
return () => {
if (win) {
remove(win, WINDOW_EVENTS, onWindowEvent);
remove(root, CLICK_EVENT, onClick);
}
router.off.value(onRouterPush);
}
}
const BASE_ATTRIBUTE_NAME = 'base';
const INITIAL_ROUTE = 'initialRoute';
const ON_STARTED_ATTRIBUTE_NAME = 'onStarted';
const routerHoc$1 = ({ slots, attributes, props }) => {
if (routerHoc$1.wasInitialized)
panic('Multiple <router> components are not supported');
return {
slot: null,
el: null,
teardown: null,
mount(el, context) {
const initialRouteAttr = getAttribute(attributes, INITIAL_ROUTE, context);
const initialRoute = initialRouteAttr
? initialRouteAttr.evaluate(context)
: null;
const currentRoute = getCurrentRoute();
const onFirstRoute = () => {
this.createSlot(context);
router.off.value(onFirstRoute);
};
routerHoc$1.wasInitialized = true;
this.el = el;
this.teardown = initDomListeners(this.root);
this.setBase(context);
// mount the slots only if the current route was defined
if (currentRoute && !initialRoute) {
this.createSlot(context);
} else {
router.on.value(onFirstRoute);
router.push(initialRoute || window.location.href);
}
},
createSlot(context) {
if (!slots || !slots.length) return
const onStartedAttr = getAttribute(
attributes,
ON_STARTED_ATTRIBUTE_NAME,
context,
);
this.slot = createDefaultSlot();
this.slot.mount(
this.el,
{
slots,
},
context,
);
if (onStartedAttr) {
onStartedAttr.evaluate(context)(getCurrentRoute());
}
},
update(context) {
this.setBase(context);
// defer the updates to avoid internal recursive update calls
// see https://github.com/riot/route/issues/148
if (this.slot) {
cancelDefer(this.deferred);
this.deferred = defer(() => {
this.slot.update({}, context);
});
}
},
unmount(...args) {
this.teardown();
routerHoc$1.wasInitialized = false;
if (this.slot) {
this.slot.unmount(...args);
}
},
getBase(context) {
const baseAttr = getAttribute(attributes, BASE_ATTRIBUTE_NAME, context);
return baseAttr
? baseAttr.evaluate(context)
: this.el.getAttribute(BASE_ATTRIBUTE_NAME) || '/'
},
setBase(context) {
setBase(props ? props.base : this.getBase(context));
},
}
};
// flag to avoid multiple router instances
routerHoc$1.wasInitialized = false;
var routerHoc = {
css: null,
exports: riot.pure(
routerHoc$1
),
template: null,
name: 'router-hoc'
};
exports.Route = routeHoc;
exports.Router = routerHoc;
exports.configure = configure;
exports.createURLStreamPipe = createURLStreamPipe;
exports.defaults = defaults;
exports.getCurrentRoute = getCurrentRoute;
exports.initDomListeners = initDomListeners;
exports.match = match;
exports.route = createRoute;
exports.router = router;
exports.setBase = setBase;
exports.toPath = toPath;
exports.toRegexp = toRegexp;
exports.toURL = toURL;
}));