import { reactive, inject, markRaw, nextTick, readonly } from 'vue'; export const RouterSymbol = Symbol(); // we are just using URL to parse the pathname and hash - the base doesn't // matter and is only passed to support same-host hrefs. const fakeHost = `http://a.com`; const getDefaultRoute = () => ({ path: '/', component: null, // this will be set upon initial page load, which is before // the app is mounted, so it's guaranteed to be avaiable in // components data: null }); export function createRouter(loadPageModule, fallbackComponent) { const route = reactive(getDefaultRoute()); const inBrowser = typeof window !== 'undefined'; function go(href = inBrowser ? location.href : '/') { // ensure correct deep link so page refresh lands on correct files. const url = new URL(href, fakeHost); if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) { url.pathname += '.html'; href = url.pathname + url.search + url.hash; } if (inBrowser) { // save scroll position before changing url history.replaceState({ scrollPosition: window.scrollY }, document.title); history.pushState(null, '', href); } return loadPage(href); } let latestPendingPath = null; async function loadPage(href, scrollPosition = 0) { const targetLoc = new URL(href, fakeHost); const pendingPath = (latestPendingPath = targetLoc.pathname); try { let page = loadPageModule(pendingPath); // only await if it returns a Promise - this allows sync resolution // on initial render in SSR. if ('then' in page && typeof page.then === 'function') { page = await page; } if (latestPendingPath === pendingPath) { latestPendingPath = null; const { default: comp, __pageData } = page; if (!comp) { throw new Error(`Invalid route component: ${comp}`); } route.path = pendingPath; route.component = markRaw(comp); route.data = readonly(JSON.parse(__pageData)); if (inBrowser) { nextTick(() => { if (targetLoc.hash && !scrollPosition) { const target = document.querySelector(decodeURIComponent(targetLoc.hash)); if (target) { scrollTo(target, targetLoc.hash); return; } } window.scrollTo(0, scrollPosition); }); } } } catch (err) { if (!err.message.match(/fetch/)) { console.error(err); } if (latestPendingPath === pendingPath) { latestPendingPath = null; route.path = pendingPath; route.component = fallbackComponent ? markRaw(fallbackComponent) : null; } } } if (inBrowser) { window.addEventListener('click', (e) => { const link = e.target.closest('a'); if (link) { const { href, protocol, hostname, pathname, hash, target } = link; const currentUrl = window.location; // only intercept inbound links if (!e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && target !== `_blank` && protocol === currentUrl.protocol && hostname === currentUrl.hostname) { e.preventDefault(); if (pathname === currentUrl.pathname) { // scroll between hash anchors in the same page if (hash && hash !== currentUrl.hash) { history.pushState(null, '', hash); // use smooth scroll when clicking on header anchor links scrollTo(link, hash, link.classList.contains('header-anchor')); } } else { go(href); } } } }, { capture: true }); window.addEventListener('popstate', (e) => { loadPage(location.href, (e.state && e.state.scrollPosition) || 0); }); window.addEventListener('hashchange', (e) => { e.preventDefault(); }); } return { route, go }; } export function useRouter() { const router = inject(RouterSymbol); if (!router) { throw new Error('useRouter() is called without provider.'); } // @ts-ignore return router; } export function useRoute() { return useRouter().route; } function scrollTo(el, hash, smooth = false) { const pageOffset = document.querySelector('.navbar') .offsetHeight; const target = el.classList.contains('.header-anchor') ? el : document.querySelector(decodeURIComponent(hash)); if (target) { const targetTop = target.offsetTop - pageOffset - 15; // only smooth scroll if distance is smaller than screen height. if (!smooth || Math.abs(targetTop - window.scrollY) > window.innerHeight) { window.scrollTo(0, targetTop); } else { window.scrollTo({ left: 0, top: targetTop, behavior: 'smooth' }); } } }