UNPKG

svelte-pathfinder

Version:

Tiny, state-based, advanced router for SvelteJS.

1,008 lines (887 loc) 26.1 kB
function noop() { } function run(fn) { return fn(); } function run_all(fns) { fns.forEach(run); } function is_function(thing) { return typeof thing === 'function'; } function safe_not_equal(a, b) { return a != a ? b == b : a !== b || ((a && typeof a === 'object') || typeof a === 'function'); } function subscribe(store, ...callbacks) { if (store == null) { return noop; } const unsub = store.subscribe(...callbacks); return unsub.unsubscribe ? () => unsub.unsubscribe() : unsub; } function get_store_value(store) { let value; subscribe(store, _ => value = _)(); return value; } function set_current_component(component) { } const dirty_components = []; const binding_callbacks = []; const render_callbacks = []; const flush_callbacks = []; const resolved_promise = Promise.resolve(); let update_scheduled = false; function schedule_update() { if (!update_scheduled) { update_scheduled = true; resolved_promise.then(flush); } } function tick() { schedule_update(); return resolved_promise; } function add_render_callback(fn) { render_callbacks.push(fn); } // flush() calls callbacks in this order: // 1. All beforeUpdate callbacks, in order: parents before children // 2. All bind:this callbacks, in reverse order: children before parents. // 3. All afterUpdate callbacks, in order: parents before children. EXCEPT // for afterUpdates called during the initial onMount, which are called in // reverse order: children before parents. // Since callbacks might update component values, which could trigger another // call to flush(), the following steps guard against this: // 1. During beforeUpdate, any updated components will be added to the // dirty_components array and will cause a reentrant call to flush(). Because // the flush index is kept outside the function, the reentrant call will pick // up where the earlier call left off and go through all dirty components. The // current_component value is saved and restored so that the reentrant call will // not interfere with the "parent" flush() call. // 2. bind:this callbacks cannot trigger new flush() calls. // 3. During afterUpdate, any updated components will NOT have their afterUpdate // callback called a second time; the seen_callbacks set, outside the flush() // function, guarantees this behavior. const seen_callbacks = new Set(); let flushidx = 0; // Do *not* move this inside the flush() function function flush() { // Do not reenter flush while dirty components are updated, as this can // result in an infinite loop. Instead, let the inner flush handle it. // Reentrancy is ok afterwards for bindings etc. if (flushidx !== 0) { return; } do { // first, call beforeUpdate functions // and update components try { while (flushidx < dirty_components.length) { const component = dirty_components[flushidx]; flushidx++; set_current_component(component); update(component.$$); } } catch (e) { // reset dirty state to not end up in a deadlocked state and then rethrow dirty_components.length = 0; flushidx = 0; throw e; } dirty_components.length = 0; flushidx = 0; while (binding_callbacks.length) binding_callbacks.pop()(); // then, once components are updated, call // afterUpdate functions. This may cause // subsequent updates... for (let i = 0; i < render_callbacks.length; i += 1) { const callback = render_callbacks[i]; if (!seen_callbacks.has(callback)) { // ...so guard against infinite loops seen_callbacks.add(callback); callback(); } } render_callbacks.length = 0; } while (dirty_components.length); while (flush_callbacks.length) { flush_callbacks.pop()(); } update_scheduled = false; seen_callbacks.clear(); } function update($$) { if ($$.fragment !== null) { $$.update(); run_all($$.before_update); const dirty = $$.dirty; $$.dirty = [-1]; $$.fragment && $$.fragment.p($$.ctx, dirty); $$.after_update.forEach(add_render_callback); } } const subscriber_queue = []; /** * Creates a `Readable` store that allows reading by subscription. * @param value initial value * @param {StartStopNotifier}start start and stop notifications for subscriptions */ function readable(value, start) { return { subscribe: writable(value, start).subscribe }; } /** * Create a `Writable` store that allows both updating and reading by subscription. * @param {*=}value initial value * @param {StartStopNotifier=}start start and stop notifications for subscriptions */ function writable(value, start = noop) { let stop; const subscribers = new Set(); function set(new_value) { if (safe_not_equal(value, new_value)) { value = new_value; if (stop) { // store is ready const run_queue = !subscriber_queue.length; for (const subscriber of subscribers) { subscriber[1](); subscriber_queue.push(subscriber, value); } if (run_queue) { for (let i = 0; i < subscriber_queue.length; i += 2) { subscriber_queue[i][0](subscriber_queue[i + 1]); } subscriber_queue.length = 0; } } } } function update(fn) { set(fn(value)); } function subscribe(run, invalidate = noop) { const subscriber = [run, invalidate]; subscribers.add(subscriber); if (subscribers.size === 1) { stop = start(set) || noop; } run(value); return () => { subscribers.delete(subscriber); if (subscribers.size === 0) { stop(); stop = null; } }; } return { set, update, subscribe }; } function derived(stores, fn, initial_value) { const single = !Array.isArray(stores); const stores_array = single ? [stores] : stores; const auto = fn.length < 2; return readable(initial_value, (set) => { let inited = false; const values = []; let pending = 0; let cleanup = noop; const sync = () => { if (pending) { return; } cleanup(); const result = fn(single ? values[0] : values, set); if (auto) { set(result); } else { cleanup = is_function(result) ? result : noop; } }; const unsubscribers = stores_array.map((store, i) => subscribe(store, (value) => { values[i] = value; pending &= ~(1 << i); if (inited) { sync(); } }, () => { pending |= (1 << i); })); inited = true; sync(); return function stop() { run_all(unsubscribers); cleanup(); }; }); } const specialLinks = /^((mailto:)|(tel:)|(sms:)|(data:)|(blob:)|(javascript:)|(ftp(s?):\/\/)|(file:\/\/))/; const hasLocation = typeof location !== 'undefined'; const hasProcess = typeof process !== 'undefined'; const hasHistory = typeof history !== 'undefined'; const hasPushState = hasHistory && isFn(history.pushState); const hasWindow = typeof window !== 'undefined'; const isSubWindow = hasWindow && window !== window.parent; const isFileScheme = hasLocation && (location.protocol === 'file:' || /[-_\w]+[.][\w]+$/i.test(location.pathname)); const sideEffect = hasWindow && hasHistory && hasLocation && !isSubWindow; const useHashbang = !hasPushState || isFileScheme; const hashbang = '#!'; const prefs = { array: { separator: ',', format: 'bracket', }, convertTypes: true, breakHooks: true, hashbang: false, anchor: false, scroll: false, focus: false, nesting: 3, sideEffect, base: '', }; function getPath() { const pathname = getLocation().pathname; if (!pathname) return; const base = getBase(); const path = trimPrefix(pathname, base); return prependPrefix(path); } function getLocation() { if (!hasLocation) return {}; if (prefs.hashbang || useHashbang) { const hash = location.hash; return new URL( hash.indexOf(hashbang) === 0 ? hash.substring(2) : hash.substring(1), 'file:' ); } return location; } function getBase() { if (prefs.base) return prefs.base; if (hasLocation && (prefs.hashbang || useHashbang)) return location.pathname; return '/'; } function getFullURL(url) { (prefs.hashbang || useHashbang) && (url = hashbang + url); const base = getBase(); return (base[base.length - 1] === '/' ? base.substring(0, base.length - 1) : base) + url; } function getShortURL(url) { url = trimPrefix(url, getLocation().origin); const base = getBase(); url = trimPrefix(url, base); (prefs.hashbang || useHashbang) && (url = trimPrefix(url, hashbang)); return prependPrefix(url); } function isBtn(el) { const tagName = el.tagName.toLowerCase(); const type = el.type && el.type.toLowerCase(); return ( tagName === 'button' || (tagName === 'input' && ['button', 'submit', 'image'].includes(type)) ); } function closest(el, tagName) { while (el && el.nodeName.toLowerCase() !== tagName) el = el.parentNode; return !el || el.nodeName.toLowerCase() !== tagName ? null : el; } function setScroll(scroll, hash = '') { const anchor = trimPrefix(normalizeHash(hash), '#'); if (scroll && prefs.scroll) { const opts = isObj(prefs.scroll) ? { ...prefs.scroll, ...scroll } : scroll; const { top = 0, left = 0 } = scroll; const { scrollHeight, scrollWidth } = document.documentElement; if (top <= scrollHeight && left <= scrollWidth) return scrollTo(opts); const cancel = observeResize((entries) => { if (!entries[0]) return cancel(); if ( (!top || entries[0].contentRect.height >= top) && (!left || entries[0].contentRect.width >= left) ) { cancel(); scrollTo(opts); } }, document.documentElement); } else if (anchor && prefs.anchor) { const opts = isObj(prefs.anchor) ? prefs.anchor : {}; const el = document.getElementById(anchor); if (el) return scrollTo(opts, el); const cancel = observeDom(() => { const el = document.getElementById(anchor); if (el) { cancel(); scrollTo(opts, el); } }); } else if (prefs.scroll) { scrollTo(); } } function setFocus(keepFocusId, activeElement) { if (!prefs.focus) return; setTimeout(() => { const autofocus = focus(); if (autofocus) return autofocus.focus(); const cancel = observeDom(() => { const autofocus = focus(); if (autofocus) { cancel(); autofocus.focus(); } }); const body = document.body; const tabindex = body.getAttribute('tabindex'); body.tabIndex = -1; body.focus({ preventScroll: true }); if (tabindex !== null) { body.setAttribute('tabindex', tabindex); } else { body.removeAttribute('tabindex'); } getSelection().removeAllRanges(); }); function focus() { if (keepFocusId) { return document.getElementById(keepFocusId); } else if ( document.activeElement !== activeElement && document.activeElement !== document.body ) { return document.activeElement; } else { return document.querySelector('[autofocus]'); } } } function parseQuery(str = '', { decode = decodeURIComponent } = {}) { return str ? str .replace('?', '') .replace(/\+/g, ' ') .split('&') .filter(Boolean) .reduce((obj, p) => { let [key, val] = p.split(/=(.*)/, 2); key = decode(key || ''); val = decode(val || ''); let o = parseKeys(key, val); obj = Object.keys(o).reduce((obj, key) => { const val = prefs.convertTypes ? convertType(o[key]) : o[key]; if (obj[key]) { Array.isArray(obj[key]) ? (obj[key] = obj[key].concat(val)) : Object.assign(obj[key], val); } else { obj[key] = val; } return obj; }, obj); return obj; }, {}) : {}; } function stringifyQuery(obj = {}, { encode = encodeURIComponent } = {}) { return Object.keys(obj) .reduce((a, k) => { if (Object.prototype.hasOwnProperty.call(obj, k) && isNaN(parseInt(k, 10))) { if (Array.isArray(obj[k])) { if (prefs.array.format === 'separator') { a.push(`${k}=${obj[k].join(prefs.array.separator)}`); } else { obj[k].forEach((v) => a.push(`${k}[]=${encode(v)}`)); } } else if (isObj(obj[k])) { let o = parseKeys(k, obj[k]); a.push(stringifyObj(o)); } else { a.push(`${k}=${encode(obj[k])}`); } } return a; }, []) .join('&'); } function injectParams(pattern, params, { encode = encodeURIComponent } = {}) { return pattern.replace(/(\/|^)([:*][^/]*?)(\?)?(?=[/.]|$)/g, (param, _, key) => { param = params[key === '*' ? 'wild' : key.substring(1)]; return param ? `/${encode(param)}` : ''; }); } function parseParams( path = '', pattern = '*', { loose = false, sensitive = false, blank = false, decode = decodeURIComponent } = {} ) { const blanks = {}; const rgx = pattern instanceof RegExp ? pattern : pattern.split('/').reduce((rgx, seg, i, { length }) => { if (seg) { const pfx = seg[0]; if (pfx === '*') { blanks['wild'] = undefined; rgx += '/(?<wild>.*)'; } else if (pfx === ':') { const opt = seg.indexOf('?', 1); const ext = seg.indexOf('.', 1); const isOpt = !!~opt; const isExt = !!~ext; const key = seg.substring(1, isOpt ? opt : isExt ? ext : seg.length); blanks[key] = undefined; rgx += isOpt && !isExt ? `(?:/(?<${key}>[^/]+?))?` : `/(?<${key}>[^/]+?)`; if (isExt) rgx += `${isOpt ? '?' : ''}\\${seg.substring(ext)}`; } else { rgx += `/${seg}`; } } if (i === length - 1) { rgx += loose ? '(?:$|/)' : '/?$'; } return rgx; }, '^'); const flags = sensitive ? '' : 'i'; const matches = new RegExp(rgx, flags).exec(path); return matches ? Object.entries(matches.groups || {}).reduce((params, [key, val]) => { const value = decode(val); params[key] = prefs.convertTypes ? convertType(value) : value; return params; }, {}) : blank ? blanks : null; } function normalizeHash(fragment, { decode = decodeURIComponent } = {}) { return decode(fragment); } function prependPrefix(str, pfx = '/', strict = false) { str += ''; return !str && strict ? str : str.indexOf(pfx) !== 0 ? pfx + str : str; } function trimPrefix(str, pfx) { return (str + '').indexOf(pfx) === 0 ? str.substring(pfx.length) : str; } function isObj(obj) { return !Array.isArray(obj) && typeof obj === 'object' && obj !== null; } function isFn(fn) { return typeof fn === 'function'; } function shallowCopy(value) { if (typeof value !== 'object' || value === null) return value; return Object.create(Object.getPrototypeOf(value), Object.getOwnPropertyDescriptors(value)); } function hookLauncher(hooks) { return (...args) => { const arr = [...hooks]; return !(prefs.breakHooks ? arr.some((cb) => cb(...args) === false) : arr.reduce((stop, cb) => cb(...args) === false || stop, false)); }; } function listenEvent(...args) { window.addEventListener(...args); return () => window.removeEventListener(...args); } function scrollTo({ top = 0, left = 0, ...opts } = {}, el) { if (el) { document.documentElement.scrollIntoView ? el.scrollIntoView({ behavior: 'smooth', ...opts }) : window.scrollTo({ top: el.offsetTop - top, behavior: 'smooth', ...opts }); } else { window.scrollTo({ top, left, behavior: 'smooth', ...opts }); } } function observeResize(cb, el, t = 5000) { const observer = new ResizeObserver(cb); observer.observe(el); const off = () => observer.unobserve(el); setTimeout(off, t); return off; } function observeDom(cb, t = 5000) { const observer = new MutationObserver(cb); observer.observe(document.body, { childList: true, subtree: true, }); const off = () => observer.disconnect(); setTimeout(off, t); return off; } function convertType(val) { if (Array.isArray(val)) { val[val.length - 1] = convertType(val[val.length - 1]); return val; } else if (typeof val === 'object') { return Object.entries(val).reduce((obj, [k, v]) => { obj[k] = convertType(v); return obj; }, {}); } if (val === 'true' || val === 'false') { return val === 'true'; } else if (val === 'null') { return null; } else if (val === 'undefined') { return undefined; } else if (val !== '' && !isNaN(Number(val)) && Number(val).toString() === val) { return Number(val); } else if (prefs.array.format === 'separator' && typeof val === 'string') { const arr = val.split(prefs.array.separator); return arr.length > 1 ? arr : val; } return val; } function parseKeys(key, val) { const brackets = /(\[[^[\]]*])/, child = /(\[[^[\]]*])/g; let seg = brackets.exec(key), parent = seg ? key.slice(0, seg.index) : key, keys = []; parent && keys.push(parent); let i = 0; while ((seg = child.exec(key)) && i < prefs.nesting) { i++; keys.push(seg[1]); } seg && keys.push(`[${key.slice(seg.index)}]`); return parseObj(keys, val); } function parseObj(chain, val) { let leaf = val; for (let i = chain.length - 1; i >= 0; --i) { let root = chain[i], obj; if (root === '[]') { obj = [].concat(leaf); } else { obj = {}; const key = root.charAt(0) === '[' && root.charAt(root.length - 1) === ']' ? root.slice(1, -1) : root, j = parseInt(key, 10); if (!isNaN(j) && root !== key && String(j) === key && j >= 0) { obj = []; obj[j] = prefs.convertTypes ? convertType(leaf) : leaf; } else { obj[key] = leaf; } } leaf = obj; } return leaf; } function stringifyObj(obj = {}, nesting = '') { return Object.entries(obj) .map(([key, val]) => { if (typeof val === 'object') { return stringifyObj(val, nesting ? `${nesting}[${key}]` : key); } else { return `${nesting}[${key}]=${val}`; } }) .join('&'); } const pathable = createParsableStore(function path($path = '') { if (typeof $path === 'string') $path = trimPrefix($path, '/').split('/'); return !Object.prototype.hasOwnProperty.call($path, 'toString') ? Object.defineProperty($path, 'toString', { value() { return prependPrefix(this.join('/')); }, configurable: false, writable: false, }) : $path; }); const queryable = createParsableStore(function query($query = '') { if (typeof $query === 'string') $query = parseQuery($query); return !Object.prototype.hasOwnProperty.call($query, 'toString') ? Object.defineProperty($query, 'toString', { value() { return prependPrefix(stringifyQuery(this), '?', true); }, configurable: false, writable: false, }) : $query; }); const fragmentable = createParsableStore(function fragment($fragment = '') { return prependPrefix(normalizeHash($fragment), '#', true); }); function createParamStore(path) { return (pattern, options = {}) => { if (pattern instanceof RegExp) throw new Error('Paramable does not support RegExp patterns.'); let params; pattern = pattern.replace(/\/$/, ''); const { subscribe } = writable({}, (set) => { return path.subscribe(($path) => { params = parseParams($path.toString(), pattern, { blank: true, ...options }); set(shallowCopy(params)); }); }); function set(value = {}) { if (Object.entries(params).some(([key, val]) => val !== value[key])) { path.update(($path) => { const tail = options.loose ? prependPrefix($path.slice(pattern.split('/').length - 1).join('/')) : ''; return injectParams(pattern + tail, value); }); } } return { get() { return get_store_value(this); }, update(fn) { set(fn(this.get())); }, subscribe, set, }; }; } function createParsableStore(parse) { return (value, cbx) => { let serialized = value && value.toString(); !Array.isArray(cbx) && (cbx = [cbx]); const hooks = new Set(cbx); const runHooks = hookLauncher(hooks); const { subscribe, set } = writable((value = parse(value)), () => () => hooks.clear()); function update(val) { val = parse(val); if (val.toString() !== serialized && runHooks(val, value, parse.name) !== false) { serialized = val.toString(); value = val; set(value); } } runHooks(null, value, parse.name); return { subscribe, update(fn) { update(fn(get_store_value(this))); }, set(value) { update(value); }, hook(cb) { if (isFn(cb)) { hooks.add(cb); cb(null, value, parse.name); } return () => hooks.delete(cb); }, }; }; } const pathname = getPath(); const { search, hash } = getLocation(); let init = true; let popstate = false; let replace = false; let len = 0; const path = pathable(pathname, before); const query = queryable(search, before); const fragment = fragmentable(hash, before); const state = writable({}); const url = derived( [path, query, fragment], ([$path, $query, $fragment], set) => { let skip = false; tick().then(() => { if (skip) return; set($path + $query + $fragment); }); return () => (skip = true); }, pathname + search + hash ); const pattern = derived(path, ($path) => parseParams.bind(null, $path.toString())); function before() { if (!prefs.scroll && !prefs.focus) return; state.update(($state = {}) => { prefs.scroll && ($state._scroll = { top: window.pageYOffset, left: window.pageXOffset, }); prefs.focus && ($state._focus = document.activeElement.id); return $state; }); } function after(url, state) { const anchor = url.indexOf('#') >= 0 ? url.slice(url.indexOf('#')) : ''; const activeElement = document.activeElement; !isObj(state) && (state = {}); tick() .then(() => setFocus(state._focus, activeElement)) .then(() => setScroll(state._scroll, anchor)); } if (sideEffect || isSubWindow) { const cleanup = new Set(); cleanup.add( url.subscribe(($url) => { if (!init && !popstate && prefs.sideEffect) { if (hasPushState) { history[replace ? 'replaceState' : 'pushState']({}, null, getFullURL($url)); } else { location.hash = getFullURL($url); } } !popstate && after($url); !replace && len++; init = replace = popstate = false; }) ); if (hasPushState) { cleanup.add( state.subscribe(($state) => { if (init || !prefs.sideEffect) return; history.replaceState( $state, null, location.pathname + location.search + location.hash ); }) ); cleanup.add( listenEvent('popstate', (e) => { popstate = true; goto(location.href, e.state); after(getShortURL(location.href), e.state); }) ); } else { cleanup.add( listenEvent('hashchange', () => { popstate = true; if (!prefs.hashbang && !useHashbang) return fragment.set(location.hash); goto(location.hash); after(getShortURL(location.hash)); }) ); } cleanup.add( listenEvent( 'beforeunload', () => { cleanup.forEach((off) => off()); cleanup.clear(); }, true ) ); } function goto(url = '', data = {}) { const { pathname, search, hash } = url instanceof URL ? url : new URL(getShortURL(url), 'file:'); path.set(pathname); query.set(search); fragment.set(hash); tick().then(() => state.set(data || {})); } function back(url) { if (len > 0 && sideEffect && prefs.sideEffect) { history.back(); len--; } else { tick().then(() => goto(url)); } } function redirect(url, data) { tick().then(() => { replace = true; goto(url, data); }); } function click(e) { if ( !e.target || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.button || e.which !== 1 || e.defaultPrevented ) return; const a = closest(e.target, 'a'); if ( !a || a.target || a.hasAttribute('download') || (a.hasAttribute('rel') && a.getAttribute('rel').includes('external')) ) return; const href = a.getAttribute('href'); const url = a.href; if ( !href || url.indexOf(location.origin) !== 0 || specialLinks.test(href) || (!prefs.hashbang && !useHashbang && href.startsWith('#')) ) return; e.preventDefault(); goto(url, Object.assign({}, a.dataset)); } function submit(e) { if (!e.target || e.defaultPrevented) return; const form = e.target; const btn = e.submitter || (isBtn(document.activeElement) && document.activeElement); let action = form.action; let method = form.method; let target = form.target; if (btn) { btn.hasAttribute('formaction') && (action = btn.formAction); btn.hasAttribute('formmethod') && (method = btn.formMethod); btn.hasAttribute('formtarget') && (target = btn.formTarget); } if (method && method.toLowerCase() !== 'get') return; if (target && target.toLowerCase() !== '_self') return; const { pathname, hash } = new URL(action); const search = []; const state = {}; const elements = form.elements; const len = elements.length; for (let i = 0; i < len; i++) { const element = elements[i]; if (!element.name || element.disabled) continue; if (['checkbox', 'radio'].includes(element.type) && !element.checked) { continue; } if (isBtn(element) && element !== btn) { continue; } if (element.type === 'hidden') { state[element.name] = element.value; continue; } search.push(`${element.name}=${element.value}`); } let url = prependPrefix(`${pathname}?${search.join('&')}${hash}`); if (hasProcess && url.match(/^\/[a-zA-Z]:\//)) { url = url.replace(/^\/[a-zA-Z]:\//, '/'); } e.preventDefault(); goto(url, state); } const paramable = createParamStore(path); export { back, click, fragment, goto, paramable, path, pattern, prefs, query, redirect, state, submit, url };