UNPKG

@riotjs/route

Version:

Riot.js isomorphic router

1,436 lines (1,303 loc) 47.8 kB
(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; }));