@shaun/alpinejs-router
Version:
A lightweight client-side router for Alpine.js that adds declarative routes, link navigation, dynamic params, external templates, and hash or HTML5 history modes without turning your app into a larger framework.
388 lines (320 loc) • 9.37 kB
JavaScript
import { RouterURL } from './url.js'
import { Router } from './router.js'
export function isActivePath (targetPath, currentPath) {
if (targetPath === '/') {
return currentPath === '/'
}
return currentPath === targetPath || currentPath.startsWith(targetPath + '/')
}
export function shouldHandleLinkClick (event, el, origin = location.origin) {
let href
try {
href = new URL(el.href, origin)
} catch {
return false
}
return !(
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.altKey ||
event.ctrlKey ||
event.shiftKey ||
(el.target && el.target !== '_self') ||
el.hasAttribute('download') ||
href.origin !== origin
)
}
export function getTemplateRenderMode ({ hasInlineTemplate, templateUrl, hasCachedTemplate, preloadFailed }) {
if (!templateUrl) {
return hasInlineTemplate ? 'inline' : 'missing'
}
if (hasCachedTemplate) {
return 'external'
}
if (preloadFailed && hasInlineTemplate) {
return 'inline'
}
return 'load'
}
export function getRouteTemplateRoot (el, expression, reportError = console.error) {
const root = el.content.firstElementChild
if (!root) {
return null
}
if (root.nextElementSibling) {
reportError(`Route '${expression}' must render exactly one root element`)
return null
}
return root
}
export default function (Alpine) {
const router = new Router()
let loadingCount = 0
const state = Alpine.reactive({
mode: 'web',
base: '',
href: location.href,
path: '',
query: {},
params: {},
loading: false
})
const route = {
get path () {
return state.path
},
get query () {
return state.query
},
get params () {
return state.params || {}
},
get loading () {
return state.loading
},
config (config = {}) {
const base = config.mode !== 'hash' && config.base && config.base.endsWith('/')
? config.base.slice(0, -1)
: config.base
state.mode = config.mode ?? 'web'
state.base = base ?? ''
},
push (...args) {
return push(...args)
},
replace (...args) {
return replace(...args)
},
resolve (query = {}) {
return getFreshURL(state.href).resolve(state.path, query).url
},
is (...paths) {
return router.is(getTargetURL(state.href), ...paths)
},
not (...paths) {
return router.not(getTargetURL(state.href), ...paths)
},
get notfound () {
return router.notfound(getTargetURL(state.href))
}
}
Alpine.$router = route
Alpine.magic('router', () => route)
let cachedKey, cachedURL
// Shared read-only instance; callers that mutate it via resolve() must use getFreshURL
function getTargetURL (href) {
const key = `${state.mode}|${state.base}|${href}`
if (key !== cachedKey) {
cachedKey = key
cachedURL = getFreshURL(href)
}
return cachedURL
}
function getFreshURL (href) {
return new RouterURL(href, { mode: state.mode, base: state.base })
}
function parse () {
const url = getTargetURL(state.href)
state.path = url.path
state.query = url.query
state.params = router.match(url)
}
Alpine.effect(() => parse())
Alpine.nextTick(() => {
if (state.mode === 'web' && !state.base) parse()
})
window.addEventListener('popstate', () => state.href = location.href)
window.addEventListener('hashchange', () => state.href = location.href)
function push (path, options = {}) {
let target = null
try {
target = new URL(path)
} catch {
target = null
}
if (target) {
if (target.origin !== location.origin) {
console.error(`Cannot push cross-origin URL '${path}'`)
return
}
} else if (state.mode === 'hash') {
path = location.origin + (state.base || '/') + '#' + path
} else {
path = location.origin + state.base + path
}
if (location.href !== path) {
history[options.replace ? 'replaceState' : 'pushState']({}, '', path)
state.href = path
}
}
function replace (path) {
push(path, { replace: true })
}
function trackLoading (promise) {
loadingCount += 1
state.loading = true
return promise.finally(() => {
loadingCount = Math.max(loadingCount - 1, 0)
state.loading = loadingCount > 0
})
}
function reportTemplateError (route, tpl, error) {
console.error(`Failed to load template '${tpl}' for route '${route}'`, error)
}
const templateCaches = Object.create(null)
const inLoadProgress = Object.create(null)
const inMakeProgress = new WeakSet()
Alpine.directive('route', (el, { modifiers, expression }, { effect, cleanup }) => {
let invalidRoute = false
if (!modifiers.includes('notfound')) {
try {
router.add(expression)
} catch (error) {
console.error(error.message)
invalidRoute = true
}
}
const load = url => {
const render = html => {
el.innerHTML = html
return html
}
if (inLoadProgress[url]) {
return inLoadProgress[url].then(render)
}
inLoadProgress[url] = fetch(url)
.then(r => {
if (!r.ok) {
throw new Error(`Failed to fetch template: ${r.status} ${r.statusText}`)
}
return r.text()
})
.then(html => {
templateCaches[url] = html
return html
})
.finally(() => delete inLoadProgress[url])
return inLoadProgress[url].then(render)
}
const tpl = RouterURL.resolveTemplatePath(
location.pathname,
el.getAttribute('template') ?? el.getAttribute('template.preload')
)
let loading
let preloadFailed = false
if (el.hasAttribute('template.preload')) {
loading = trackLoading(load(tpl))
.then(() => true)
.catch(error => {
preloadFailed = true
reportTemplateError(expression, tpl, error)
return false
})
.finally(() => { loading = false })
}
function show () {
if (el._x_currentIfEl) return el._x_currentIfEl
const make = () => {
if (inMakeProgress.has(el)) return
const root = getRouteTemplateRoot(el, expression)
if (!root) {
return
}
inMakeProgress.add(el)
const clone = root.cloneNode(true)
Alpine.addScopeToNode(clone, {}, el)
Alpine.mutateDom(() => {
el.after(clone)
Alpine.initTree(clone)
})
el._x_currentIfEl = clone
el._x_undoIf = () => {
clone.remove()
delete el._x_currentIfEl
}
Alpine.nextTick(() => inMakeProgress.delete(el))
}
const renderInline = () => {
if (el.content.firstElementChild) {
make()
return true
}
return false
}
const mode = getTemplateRenderMode({
hasInlineTemplate: Boolean(el.content.firstElementChild),
templateUrl: tpl,
hasCachedTemplate: tpl in templateCaches,
preloadFailed
})
if (mode === 'inline') {
renderInline()
} else if (mode === 'external') {
if (tpl in templateCaches) {
el.innerHTML = templateCaches[tpl]
make()
}
} else if (mode === 'load') {
if (loading) {
loading.then(loaded => loaded ? make() : renderInline())
} else {
trackLoading(load(tpl))
.then(() => make())
.catch(error => {
reportTemplateError(expression, tpl, error)
if (!renderInline()) {
console.error(`Template for '${expression}' is missing`)
}
})
}
} else {
console.error(`Template for '${expression}' is missing`)
}
}
function hide () {
if (el._x_undoIf) {
el._x_undoIf()
delete el._x_undoIf
}
}
Alpine.nextTick(() => {
effect(() => {
if (invalidRoute) {
hide()
return
}
const target = getTargetURL(state.href)
const found = modifiers.includes('notfound') ? router.notfound(target) : router.is(target, expression)
found ? show() : hide()
})
})
cleanup(() => el._x_undoIf && el._x_undoIf())
})
Alpine.directive('link', (el, { modifiers, expression }, { evaluate, effect, cleanup }) => {
const url = getFreshURL(el.href)
el.href = url.resolve(url.path, url.query, true).url
function go (e) {
if (!shouldHandleLinkClick(e, el, location.origin)) {
return
}
e.preventDefault()
push(el.href, { replace: modifiers.includes('replace') })
}
el.addEventListener('click', go)
if (modifiers.includes('activity')) {
const classes = expression ? evaluate(expression) : {}
classes.active ??= 'active'
classes.exactActive ??= 'exact-active'
const linkPath = url.path
effect(() => {
const currentPath = getTargetURL(state.href).path
el.classList.toggle(classes.active, isActivePath(linkPath, currentPath))
el.classList.toggle(classes.exactActive, linkPath === currentPath)
})
}
cleanup(() => {
el.removeEventListener('click', go)
})
})
}