UNPKG

@roxi/routify

Version:

515 lines (445 loc) 16.5 kB
import { derived } from 'svelte/store' import { contexts, populateUrl } from '../utils/index.js' import { get } from 'svelte/store' export * from './preload.js' /** * gets most recent common ancestor * @param {RNodeRuntime} node1 * @param {RNodeRuntime} node2 */ export const getMRCA = (node1, node2) => { const lineage1 = [node1, ...node1.ancestors] const lineage2 = [node2, ...node2.ancestors] const mrca = lineage1.find(node => lineage2.includes(node)) const index1 = lineage1.indexOf(mrca) const index2 = lineage2.indexOf(mrca) const descendants1 = lineage1.slice(0, index1).reverse() const descendants2 = lineage2.slice(0, index2).reverse() return { mrca, index1, index2, lineage1, lineage2, descendants1, descendants2 } } export const getPath = (node1, node2) => { const { index1, index2, lineage2 } = getMRCA(node1, node2) const backtrackSteps = index1 const backtrackStr = backtrackSteps ? '../'.repeat(backtrackSteps) : '' const forwardSteps = index2 const forwardStepsStr = lineage2 .slice(0, forwardSteps) .reverse() .map(n => n.name) .join('/') return backtrackStr + forwardStepsStr } /** * @template T * @typedef {import('svelte/store').Readable<T>} Readable */ /** * @typedef {Object} IsActiveOptions * @prop {Boolean} [recursive=true] return true if descendant is active */ /** * @typedef {Object} $UrlOptions * @prop {boolean} strict Require internal paths. Eg. `/blog/[slug]` instead of `/blog/hello-world` * @prop {boolean} includeIndex suffix path with `/index` * @prop {boolean} silent suppress errors * @prop {'push'|'replace'} mode push to or replace in navigation history * @prop {Router} router the router to use * @prop {RNodeRuntime} node origin node */ /** * @typedef {Partial<{ * dontscroll: boolean, * dontsmoothscroll: boolean, * [key:string]: * * }>} RouteState */ /** * @callback Goto * @param {string|RNodeRuntime} pathOrNode relative, absolute or named URL * @param {Object.<string, string>=} userParams * @param {Partial<$UrlOptions & RouteState>=} options * @type {Readable<Goto>} */ export const goto = { subscribe: (run, invalidate) => { const { router } = contexts return derived(url, $url => /** @type {Goto} */ (pathOrNode, userParams, options) => { /** @type {options} */ const defaults = { mode: 'push', state: {} } options = { ...defaults, ...options } const newUrl = $url(pathOrNode, userParams, options) router.url[options.mode](newUrl, options.state) return '' }, ).subscribe(run, invalidate) }, } /** * @typedef {(<T extends string | RNodeRuntime | HTMLAnchorElement>( * inputPath: T, * userParams?: { [x: string]: string; }, * options?: Partial<$UrlOptions> * ) => T extends HTMLAnchorElement ? void : string)} Url */ /** * @type {Readable<Url>} */ export const url = { subscribe: (run, invalidate) => { // fragment doesn't always contain .route.router, it could be an inactive inlined page const { fragment, router } = contexts return derived(fragment.params, $params => { return (pathElemOrNode, params, options) => { const createUrl = getCreateUrl(router, fragment.fragment) // if we're dealing with a string, return the rendered url if (!(globalThis.HTMLElement && pathElemOrNode instanceof HTMLElement)) return createUrl(pathElemOrNode, params, options) // if we're dealing with an anchor element, update it const path = pathElemOrNode.getAttribute('href') pathElemOrNode.setAttribute('href', createUrl(path, $params, options)) } }).subscribe(run, invalidate) }, } /** * @typedef {(( * pathOrNode: string|RNodeRuntime, * userParams?: { [x: string]: string; }, * options?: Partial<$UrlOptions> * ) => string)} UrlFromString */ /** * @param {Router} router * @param {RouteFragment=} fragment * @returns {UrlFromString} */ export const getCreateUrl = (router, fragment) => /** @type {UrlFromString} */ (pathOrNode, userParams = {}, options = {}) => { let _inputPath = typeof pathOrNode === 'string' ? pathOrNode : pathOrNode === null ? '$leaf' : pathOrNode?.path fragment = fragment || router.activeRoute.get().leaf const route = fragment.route options.strict = options.strict ?? true // in case we swapped the routes tree (rootNode), make sure we find // the node that corresponds with the previous origin // otherwise mrca will break as there's no shared ancestor const originNode = router.rootNode.traverse(fragment.node.path) // strip the start of inputPath that matches the router's root node path if it's not '/' if (router.rootNode.path !== '/' && _inputPath.startsWith(router.rootNode.path)) _inputPath = _inputPath.substring(router.rootNode.path.length) const leafPath = route?.leaf.node.path const inputPath = _inputPath.replace('$leaf', leafPath || fragment.node.path) // we want absolute urls to be relative to the nearest router. Ironic huh const offset = inputPath.startsWith('/') ? router.rootNode.path : '' const offsetPath = (offset + inputPath).replace(/^\/+/, '/') const isNamedPath = !offsetPath.startsWith('/') && !offsetPath.startsWith('.') let targetNode let paramsFromPath = {} if (isNamedPath) { targetNode = originNode.root.instance.nodeIndex.find( n => n.meta.name === path, ) } else { const steps = originNode.getChainTo(offsetPath, { ...options, allowDynamic: !options.strict, }) // if we don't have a strict path, we need to get the params from the path paramsFromPath = Object.assign({}, ...(steps.map(cs => cs.params) || [])) targetNode = steps.pop().node } if (!targetNode) { console.error('could not find destination node', inputPath) return } // get lowest common ancestor const mrca = getMRCA(targetNode, router.rootNode).mrca const path = ('/' + getPath(mrca, targetNode)).replace(/\/index$/, '/') const params = { ...paramsFromPath, ...inheritedParams(targetNode, route || router.activeRoute.get()), ...userParams, } const hash = params['#'] delete params['#'] const urlHandler = obj => router.queryHandler.stringify(obj, route) const internalUrl = populateUrl(path, params, urlHandler) const externalUrl = router .getExternalUrl(internalUrl.path + internalUrl.query) .replace(/\/$/, '') || '/' if (hash) return externalUrl + '#' + hash return externalUrl } // todo only one target node is checked in inheritedParams /** * copies params from all fragments in the route chain * @param {RNodeRuntime} node * @param {Route} route */ const inheritedParams = (node, route) => { const params = route.allFragments.map( // if node is a descendant of the fragment's node, return params fragment => fragment.node.getChainToNode(node) && fragment.params, ) return Object.assign({}, ...params) } /** * @type {Readable<Object.<string, any>>} */ export const params = { subscribe: (run, invalidate) => { return contexts.router.params.subscribe(run, invalidate) }, } const stripUndef = o => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) /** * @type {Readable<(params: Object.<string, any>) => void>} */ export const setParams = derived( goto, $goto => params => $goto('$leaf', stripUndef(params)), ) /** * @type {Readable<(callback: (params: Object.<string, any>) => Object.<string, any>) => void>} */ export const updateParams = derived( [setParams, params], ([$setParams, $params]) => callback => $setParams(callback({ ...$params })), ) /** * @callback IsActive * @param {String|RNodeRuntime=} pathOrNode * @param {Object.<string,string>} [params] * @param {IsActiveOptions} [options] * @returns {Boolean} * * @type {Readable<IsActive>} */ export const isActive = { subscribe: (run, invalidate) => { const { fragment, router } = contexts return derived(router.activeRoute, () => isActiveUrl(fragment)).subscribe( run, invalidate, ) }, } /** * * @param {RenderContext} context * @param {string} path * @param {Object.<string, string>} params params to match against * */ const traverseContext = (context, path, params) => { const breadcrumbs = path.split('/') let targetContexts = [context] for (const crumb of breadcrumbs) { if (!targetContexts) return false if (crumb === '..') { targetContexts = [targetContexts[0].parentContext] } else if (crumb != '.') { const childContexts = targetContexts.map(ctx => get(ctx.childContexts)).flat() targetContexts = childContexts.filter( childContext => childContext.node.name === crumb, ) } } const targetContext = targetContexts.find(ctx => { // get all ancestor params. ctx does not have ancestor prop. Only parentContext const paramsStores = [ctx.params, ...ctx.ancestors.map(ctx => ctx.params)] const existingParams = Object.assign({}, ...paramsStores.map(store => get(store))) // make sure each param in the params object is present in the context chain const allParamsArePresent = Object.entries(params).every( ([key, value]) => existingParams[key] === value, ) return allParamsArePresent }) return targetContext } export const isActiveFragment = { subscribe: (run, invalidate) => { const { fragment: context, router } = contexts const refresh = () => { /** * @param {string} path * @param {Object.<string, string | string[]>} params * @param {IsActiveOptions} options * @param {boolean} options.recursive also check if all ancestors are active */ run((path, params, options) => { options = { recursive: false, ...options } const targetContext = traverseContext(context, path, params) if (!targetContext) return false const isActiveStores = [ targetContext.isActive, ...((options.recursive && targetContext.ancestors.map(ctx => ctx.isActive)) || []), ] const isActive = isActiveStores.map(store => get(store)).every(Boolean) return isActive }) } // need this or we get a ctx[2] is not a function error refresh() return router.activeRoute.subscribe( () => router.ready().then(refresh), invalidate, ) }, } /** * * @param {RenderContext} renderContext * @returns */ export const isActiveUrl = renderContext => { const { router, fragment } = renderContext /** @type {IsActive} */ return (pathOrNode, params = {}, options = {}) => { let _path = typeof pathOrNode === 'string' ? pathOrNode : pathOrNode?.path /** * @type {{recursive: boolean, silent: TraverseOptions['silent']}} */ const { recursive, silent } = { recursive: true, silent: 'report', ...options } const route = router.activeRoute.get() // if we're using a custom rootNode, we need to strip it from the path if (router.rootNode.path !== '/') _path = _path.substring(router.rootNode.path.length) /** * @type {TraverseOptions} */ const chainOptions = { rootNode: router.rootNode, allowDynamic: false, includeIndex: !recursive, silent, } const allWantedParamsAreInActiveChain = Object.entries(params).every( ([key, value]) => route.params[key] === value, ) if (!allWantedParamsAreInActiveChain) return false const wantedNode = _path.startsWith('.') ? fragment.node.traverse(_path, chainOptions) : router.rootNode.getChainTo(_path, chainOptions).pop().node const actNodes = [...route.fragments.map(fragment => fragment.node)] return actNodes.includes(wantedNode) } } /** * @param {string} path */ export const resolveNode = path => { const { node } = contexts.fragment const { router } = contexts return traverseNode(node, path, router) } /** * * @param {RNodeRuntime} node * @param {string} path * @param {Router} router * @returns {RNodeRuntime} */ export const traverseNode = (node, path, router) => path.startsWith('/') ? router.rootNode.traverse(`.${path}`) : node.traverse(path) /** * @template {Function} T * @template U * @param {T} callback * @returns {Readable<T extends () => infer U ? U : any>} */ const pseudoStore = callback => ({ subscribe: run => { run(callback()) return () => {} }, }) export const context = pseudoStore(() => contexts.fragment) export const node = pseudoStore(() => get(context).node) export const meta = pseudoStore(() => get(node).meta) /** @type {Readable<Route>} */ export const activeRoute = { subscribe: run => contexts.router.activeRoute.subscribe(run), } /** @type {Readable<Route>} */ export const pendingRoute = { subscribe: run => contexts.router.pendingRoute.subscribe(run), } /**@type {Readable<function(AfterUrlChangeCallback):any>} */ export const afterUrlChange = { subscribe: run => { const hookHandles = [] /** * @param {AfterUrlChangeCallback} callback */ const register = callback => { const unhook = contexts.router.afterUrlChange(callback) hookHandles.push(unhook) return unhook } run(register) return () => hookHandles.map(unhook => unhook()) }, } /**@type {Readable<function(BeforeUrlChangeCallback):any>} */ export const beforeUrlChange = { subscribe: run => { const hookHandles = [] /** * @param {BeforeUrlChangeCallback} callback */ const register = callback => { const unhook = contexts.router.beforeUrlChange(callback) hookHandles.push(unhook) return unhook } run(register) return () => hookHandles.map(unhook => unhook()) }, } /** * @callback getDirectionCB * @param {RNodeRuntime=} boundary * @param {Route=} newRoute * @param {Route=} oldRoute * @returns {'first'|'last'|'same'|'next'|'prev'|'higher'|'lower'|'na'} */ /** * @type {getDirectionCB & Readable<ReturnType<getDirectionCB>>} */ export const getDirection = (boundary, newRoute, oldRoute) => { if (!newRoute) newRoute = get(context).route if (!oldRoute) oldRoute = newRoute.router.lastRoute if (!oldRoute) return 'first' const mrcaInfo = getMRCA(newRoute.leaf.node, oldRoute.leaf.node) let newNode = mrcaInfo.descendants1[0] let oldNode = mrcaInfo.descendants2[0] if (![newNode, ...(newNode?.ancestors || [])].includes(boundary)) return 'last' if (![oldNode, ...(oldNode?.ancestors || [])].includes(boundary)) return 'first' if (oldNode === newNode) return 'same' if (oldNode.meta.children?.includes(newNode.name)) return 'higher' if (newNode.meta.children?.includes(oldNode.name)) return 'lower' if (oldNode.meta.order < newNode.meta.order) return 'next' if (oldNode.meta.order > newNode.meta.order) return 'prev' return 'na' } getDirection.subscribe = (run, invalidate) => { const { router, fragment } = contexts // WHY IS THERE A BOUNDARY??? MAKES NO SENSE const boundary = fragment.node.parent return derived(router.activeRoute, $route => { return getDirection(boundary, $route, $route.router.lastRoute) }).subscribe(run, invalidate) }