Browse Source

refactorig and add test

Shaun 2 years ago
parent
commit
b41aafa502
8 changed files with 314 additions and 99 deletions
  1. 1 1
      package.json
  2. 42 98
      src/index.js
  3. 34 0
      src/pattern.js
  4. 41 0
      src/router.js
  5. 46 0
      src/url.js
  6. 34 0
      tests/pattern.test.js
  7. 37 0
      tests/router.test.js
  8. 79 0
      tests/url.test.js

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@shaun/alpinejs-router",
-  "version": "1.2.2",
+  "version": "1.2.3",
   "description": "Easy to use and flexible router for Alpine.js",
   "type": "module",
   "main": "dist/module.cjs.js",

+ 42 - 98
src/index.js

@@ -1,19 +1,20 @@
+import { RouterURL } from './url'
+import { Router } from './router'
+
 export default function (Alpine) {
+  const router = new Router()
+
   const state = Alpine.reactive({
     mode: 'web',
     base: '',
     href: location.href,
     path: '',
     query: {},
+    params: {},
     loading: false
   })
 
-  const route = Alpine.reactive({
-    patterns: {},
-    pathParams: {}
-  })
-
-  const router = {
+  const route = {
     get path () {
       return state.path
     },
@@ -21,7 +22,7 @@ export default function (Alpine) {
       return state.query
     },
     get params () {
-      return route.pathParams[state.path] ?? {}
+      return state.params
     },
     get loading () {
       return state.loading
@@ -39,27 +40,31 @@ export default function (Alpine) {
     },
 
     resolve (query = {}) {
-      let r = new URL(state.href).search
-      return state.base + (state.mode === 'hash' ? '#' : '') + state.path + '?' + new URLSearchParams({
-        ...Object.fromEntries(new URLSearchParams(r || '').entries()),
-        ...query
-      }).toString()
+      return getTargetURL(state.href).resolve(state.path, query).url
     },
 
     is (...paths) {
-      return is({ paths })
+      return router.is(paths)
     },
     not (...paths) {
-      return !is({ paths })
+      return router.not(paths)
+    },
+    get notfound () {
+      return router.notfound(getTargetURL(state.href))
     }
   }
 
-  Alpine.magic('router', () => router)
+  Alpine.magic('router', () => route)
+
+  function getTargetURL (href) {
+    return new RouterURL(href, { mode: state.mode, base: state.base })
+  }
 
   Alpine.effect(() => {
-    state.query = (state.href.indexOf('?') > -1)
-      ? Object.fromEntries(new URLSearchParams(state.href.split('?').pop()).entries())
-      : {}
+    const url = getTargetURL(state.href)
+    state.path = url.path
+    state.query = url.query
+    state.params = router.match(url)
   })
 
   window.addEventListener('popstate', () => state.href = location.href)
@@ -82,65 +87,13 @@ export default function (Alpine) {
     push(path, { replace: true })
   }
 
-  function buildPattern (path) {
-    const pattern = path.split('/').map(e => {
-      if (e.startsWith(':')) {
-        let field = e.substr(1)
-        let fieldPattern = '[^/]+'
-        const ef = field.match(/\((.+?)\)/)
-        if (ef) {
-          field = field.substr(0, field.indexOf('('))
-          fieldPattern = ef[1]
-        }
-        return `(?<${field}>${fieldPattern})`
-      }
-      return e
-    }).join('/')
-    return pattern.indexOf('(?') > -1 ? new RegExp(`^${pattern}$`) : pattern
-  }
-
-  function is ({ paths, parseParams = false }) {
-    const url = new URL(state.href)
-    const [pathname,] = (state.mode === 'hash')
-      ? url.hash.slice(1).split('?')
-      : [url.pathname.replace(state.base, ''),]
-
-    for (const path of paths) {
-      if (path === 'notfound') {
-        return Object.entries(route.patterns).findIndex(
-          e => e[1] instanceof RegExp ? pathname.match(e[1]) : pathname === e[1]
-        ) === -1
-      }
-
-      const pattern = route.patterns[path]
-      if (pattern === undefined) continue
-
-      if (pattern instanceof RegExp) {
-        if (parseParams) {
-          const m = pathname.match(pattern)
-          if (m) {
-            state.path = pathname
-            route.pathParams = { ...route.pathParams, [pathname]: { ...m.groups } }
-            return true
-          }
-        } else if (pattern.test(pathname)) {
-          return true
-        }
-      } else if (pattern === pathname) {
-        return true
-      }
-    }
-
-    return false
-  }
-
   const templateCaches = {}
   const inLoadProgress = {}
   const inMakeProgress = new Set()
 
   Alpine.directive('route', (el, { modifiers, expression }, { effect, cleanup }) => {
     if (!modifiers.includes('notfound')) {
-      route.patterns = { ...route.patterns, [expression]: buildPattern(expression) }
+      router.add(expression)
     }
 
     const load = url => {
@@ -156,10 +109,11 @@ export default function (Alpine) {
       return inLoadProgress[url]
     }
 
+    const tpl = el.getAttribute('template') ?? el.getAttribute('template.preload')
+
     let loading
     if (el.hasAttribute('template.preload')) {
-      const url = el.getAttribute('template.preload')
-      loading = load(url).finally(() => loading = false)
+      loading = load(tpl).finally(() => loading = false)
     }
 
     function show () {
@@ -191,17 +145,16 @@ export default function (Alpine) {
 
       if (el.content.firstElementChild) {
         make()
-      } else if (el.hasAttribute('template') || el.hasAttribute('template.preload')) {
-        const url = el.getAttribute('template') || el.getAttribute('template.preload')
-        if (templateCaches[url]) {
-          el.innerHTML = templateCaches[url]
+      } else if (tpl) {
+        if (templateCaches[tpl]) {
+          el.innerHTML = templateCaches[tpl]
           make()
         } else {
           if (loading) {
             loading.then(() => make())
           } else {
             state.loading = true
-            load(url).then(() => make()).finally(() => state.loading = false)
+            load(tpl).then(() => make()).finally(() => state.loading = false)
           }
         }
       } else {
@@ -216,26 +169,20 @@ export default function (Alpine) {
       }
     }
 
-    effect(() => {
-      if (modifiers.includes('notfound')) {
-        is({ paths: ['notfound'] }) ? show() : hide()
-      } else {
-        is({ paths: [expression], parseParams: true }) ? show() : hide()
-      }
+    Alpine.nextTick(() => {
+      effect(() => {
+        const target = getTargetURL(state.href)
+        const found = modifiers.includes('notfound') ? router.notfound(target) : router.is(expression)
+        found ? show() : hide()
+      })
     })
 
     cleanup(() => el._x_undoIf && el._x_undoIf())
   })
 
   Alpine.directive('link', (el, { modifiers, expression }, { evaluate, effect, cleanup }) => {
-    const url = new URL(el.href)
-    let expected
-    if (state.mode === 'hash') {
-      expected = url.origin + state.base + (url.hash || ('#' + url.pathname.replace(state.base, '') + url.search))
-    } else {
-      expected = url.origin + (url.pathname.startsWith(state.base) ? url.pathname : state.base + url.pathname) + url.search
-    }
-    if (expected !== url.href) el.href = expected
+    const url = getTargetURL(el.href)
+    el.href = url.resolve(url.path, url.query, true).url
 
     function go (e) {
       e.preventDefault()
@@ -249,13 +196,10 @@ export default function (Alpine) {
       classes.exactActive ??= 'exact-active'
 
       effect(() => {
-        const [elUrl, stateUrl] = [new URL(el.href), new URL(state.href)]
-        const [l, r] = (state.mode === 'hash')
-          ? [elUrl.hash.slice(1).split('?').shift(), stateUrl.hash.slice(1).split('?').shift()]
-          : [elUrl.pathname, stateUrl.pathname]
+        const [l, r] = [getTargetURL(el.href), getTargetURL(state.href)]
 
-        el.classList.toggle(classes.active, l !== (state.mode !== 'hash' ? state.base : '') + '/' && r.startsWith(l))
-        el.classList.toggle(classes.exactActive, l === r)
+        el.classList.toggle(classes.active, r.path.startsWith(l.path))
+        el.classList.toggle(classes.exactActive, l.path === r.path)
       })
     }
 

+ 34 - 0
src/pattern.js

@@ -0,0 +1,34 @@
+export class URLPattern {
+  static build (path) {
+    const pattern = path.split('/').map(e => {
+      if (e.startsWith(':')) {
+        let field = e.substr(1)
+        let fieldPattern = '[^/]+'
+        const ef = field.match(/\((.+?)\)/)
+        if (ef) {
+          field = field.substr(0, field.indexOf('('))
+          fieldPattern = ef[1]
+        }
+        return `(?<${field}>${fieldPattern})`
+      }
+      return e
+    }).join('/')
+    return pattern.indexOf('(?') > -1 ? new RegExp(`^${pattern}$`) : pattern
+  }
+
+  static match (path, pattern) {
+    if (pattern instanceof RegExp) {
+      const found = path.match(pattern)
+      if (!found) return false
+      return { ...found.groups }
+    }
+    return path === pattern
+  }
+
+  static is (path, pattern) {
+    if (pattern instanceof RegExp) {
+      return pattern.test(path)
+    }
+    return path === pattern
+  }
+}

+ 41 - 0
src/router.js

@@ -0,0 +1,41 @@
+import { RouterURL } from './url'
+import { URLPattern } from './pattern'
+
+export class Router {
+  #patterns = {}
+  #current
+
+  add (route) {
+    this.#patterns = {
+      ...this.#patterns,
+      [route]: URLPattern.build(route)
+    }
+  }
+
+  match (target) {
+    console.assert(target instanceof RouterURL)
+    for (const [route, pattern] of Object.entries(this.#patterns)) {
+      const found = URLPattern.match(target.path, pattern)
+      if (found) {
+        this.#current = route
+        return found === true ? {} : found
+      }
+    }
+    return false
+  }
+
+  is (...routes) {
+    return routes.indexOf(this.#current) > -1
+  }
+
+  not (...routes) {
+    return routes.indexOf(this.#current) === -1
+  }
+
+  notfound (target) {
+    console.assert(target instanceof RouterURL)
+    return Object.keys(this.#patterns).findIndex(
+      e => URLPattern.is(target.path, e)
+    ) === -1
+  }
+}

+ 46 - 0
src/url.js

@@ -0,0 +1,46 @@
+export class RouterURL {
+  #url
+
+  constructor(url, opts = {}) {
+    this.#url = new URL(url)
+    this.mode = opts.mode ?? 'web'
+    this.base = opts.base ?? ''
+  }
+
+  set url (val) {
+    this.#url = new URL(val)
+  }
+
+  get url () {
+    return this.#url.href
+  }
+
+  get path () {
+    return (this.mode === 'hash')
+      ? this.#url.hash.slice(1).split('?').shift()
+      : this.#url.pathname.replace(this.base, '')
+  }
+
+  get query () {
+    return Object.fromEntries(
+      new URLSearchParams(
+        this.mode === 'web' ? this.#url.search : this.#url.hash.split('?').pop()
+      )
+    )
+  }
+
+  resolve (path, params, replace = false) {
+    let [l, r] = this.#url.href.split('?')
+    l = (this.mode === 'hash')
+      ? l.replace(/#.+$/, '#' + path)
+      : l.replace(new RegExp(this.#url.pathname + '$'), this.base + path)
+    const q = replace
+      ? new URLSearchParams(params).toString()
+      : new URLSearchParams({
+        ...Object.fromEntries(new URLSearchParams(r ?? '').entries()),
+        ...params
+      }).toString()
+    this.url = l + (q ? '?' + q : '')
+    return this
+  }
+}

+ 34 - 0
tests/pattern.test.js

@@ -0,0 +1,34 @@
+import { URLPattern } from '../src/pattern'
+
+describe('patterns', () => {
+  test('build static path', () => {
+    const path = '/hello/world'
+    expect(URLPattern.build(path)).toBe(path)
+  })
+
+  test('build dynamic path', () => {
+    expect(URLPattern.build('/hello/:name'))
+      .toStrictEqual(/^\/hello\/(?<name>[^/]+)$/)
+  })
+
+  test('build dynamic path with regex', () => {
+    expect(URLPattern.build('/users/:id(\\d+)'))
+      .toStrictEqual(/^\/users\/(?<id>\d+)$/)
+  })
+
+  describe('matches', () => {
+    test('match', () => {
+      expect(URLPattern.match('/hello/world', URLPattern.build('/hello/world'))).toBe(true)
+      expect(URLPattern.match('/hello/world', URLPattern.build('/hello/:name'))).toStrictEqual({name: 'world'})
+      expect(URLPattern.match('/users/123', URLPattern.build('/users/:id(\\d+)'))).toStrictEqual({id: '123'})
+      expect(URLPattern.match('/users/someone', URLPattern.build('/users/:id(\\d+)'))).toBe(false)
+    })
+
+    test('is', () => {
+      expect(URLPattern.is('/hello/world', URLPattern.build('/hello/world'))).toBe(true)
+      expect(URLPattern.is('/hello/world', URLPattern.build('/hello/:name'))).toBe(true)
+      expect(URLPattern.is('/users/123', URLPattern.build('/users/:id(\\d+)'))).toBe(true)
+      expect(URLPattern.is('/users/someone', URLPattern.build('/users/:id(\\d+)'))).toBe(false)
+    })
+  })
+})

+ 37 - 0
tests/router.test.js

@@ -0,0 +1,37 @@
+import { Router } from '../src/router'
+import { RouterURL } from '../src/url'
+
+describe('router', () => {
+  test('match', () => {
+    const r = new Router()
+    r.add('/hello')
+    r.add('/users/add')
+    r.add('/users/:id(\\d+)')
+    expect(r.match(new RouterURL('http:/localhost/hello'))).toStrictEqual({})
+    expect(r.match(new RouterURL('http:/localhost/users/add'))).toStrictEqual({})
+    expect(r.match(new RouterURL('http:/localhost/users/123'))).toStrictEqual({id: '123'})
+    expect(r.match(new RouterURL('http:/localhost/xyz'))).toBe(false)
+  })
+
+  test('is, not, notfound', () => {
+    const r = new Router()
+    r.add('/hello')
+    r.add('/users/add')
+    r.add('/users/:id')
+    r.match(new RouterURL('http:/localhost/hello'))
+    expect(r.is('/hello')).toBe(true)
+    expect(r.is('/xyz')).toBe(false)
+    expect(r.is('/xyz', '/hello')).toBe(true)
+    expect(r.not('/hello')).toBe(false)
+    expect(r.not('/xyz')).toBe(true)
+    expect(r.not('/xyz', '/hello')).toBe(false)
+    r.match(new RouterURL('http:/localhost/users/add'))
+    expect(r.is('/users/add')).toBe(true)
+    expect(r.not('/users/:id')).toBe(true)
+    r.match(new RouterURL('http:/localhost/users/123'))
+    expect(r.not('/users/add')).toBe(true)
+    expect(r.is('/users/:id')).toBe(true)
+    expect(r.notfound(new RouterURL('http:/localhost/hello/world'))).toBe(true)
+    expect(r.notfound(new RouterURL('http:/localhost/hello'))).toBe(false)
+  })
+})

+ 79 - 0
tests/url.test.js

@@ -0,0 +1,79 @@
+import { RouterURL } from '../src/url'
+
+describe('URL', () => {
+  describe('web mode', () => {
+    test('basic', () => {
+      const u = new RouterURL('http://localhost/hello/world')
+      expect(u.mode).toBe('web')
+      expect(u.path).toBe('/hello/world')
+    })
+
+    test('with base', () => {
+      const u = new RouterURL('http://localhost/base/hello/world', { base: '/base' })
+      expect(u.mode).toBe('web')
+      expect(u.base).toBe('/base')
+      expect(u.path).toBe('/hello/world')
+    })
+
+    test('parse query', () => {
+      const u = new RouterURL('http://localhost/hello/world?a=1&b=c')
+      expect(u.query).toStrictEqual({a: '1', b: 'c'})
+    })
+
+    test('resolve url', () => {
+      const u = new RouterURL('http://localhost/hello/world?a=1&b=c')
+      expect(u.resolve('/xyz', {a: '123', d: 1}).url).toBe('http://localhost/xyz?a=123&b=c&d=1')
+      expect(u.resolve('/abc', {a: '123', d: 1}, true).url).toBe('http://localhost/abc?a=123&d=1')
+      expect(u.resolve('/def', {}, true).url).toBe('http://localhost/def')
+      expect(u.resolve('/', {}, true).url).toBe('http://localhost/')
+      expect(u.resolve('', {}, true).url).toBe('http://localhost/')
+    })
+
+    test('resolve url with bsae', () => {
+      const u = new RouterURL('http://localhost/base/hello/world?a=1&b=c', { base: '/base' })
+      expect(u.resolve('/xyz', {a: '123', d: 1}).url).toBe('http://localhost/base/xyz?a=123&b=c&d=1')
+      expect(u.resolve('/abc', {a: '123', d: 1}, true).url).toBe('http://localhost/base/abc?a=123&d=1')
+      expect(u.resolve('/def', {}, true).url).toBe('http://localhost/base/def')
+      expect(u.resolve('/', {}, true).url).toBe('http://localhost/base/')
+      expect(u.resolve('', {}, true).url).toBe('http://localhost/base')
+    })
+  })
+
+  describe('hash mode', () => {
+    test('basic', () => {
+      const u = new RouterURL('http://localhost/#/hello/world', { mode: 'hash' })
+      expect(u.mode).toBe('hash')
+      expect(u.path).toBe('/hello/world')
+    })
+
+    test('with base', () => {
+      const u = new RouterURL('http://localhost/base#/hello/world', { mode: 'hash', base: '/base' })
+      expect(u.mode).toBe('hash')
+      expect(u.base).toBe('/base')
+      expect(u.path).toBe('/hello/world')
+    })
+
+    test('parse query', () => {
+      const u = new RouterURL('http://localhost/#/hello/world?a=1&b=c', { mode: 'hash' })
+      expect(u.query).toStrictEqual({a: '1', b: 'c'})
+    })
+
+    test('resolve url', () => {
+      const u = new RouterURL('http://localhost/#/hello/world?a=1&b=c', { mode: 'hash' })
+      expect(u.resolve('/xyz', {a: '123', d: 1}).url).toBe('http://localhost/#/xyz?a=123&b=c&d=1')
+      expect(u.resolve('/abc', {a: '123', d: 1}, true).url).toBe('http://localhost/#/abc?a=123&d=1')
+      expect(u.resolve('/def', {}, true).url).toBe('http://localhost/#/def')
+      expect(u.resolve('/', {}, true).url).toBe('http://localhost/#/')
+      expect(u.resolve('', {}, true).url).toBe('http://localhost/#')
+    })
+
+    test('resolve url with base', () => {
+      const u = new RouterURL('http://localhost/base#/hello/world?a=1&b=c', { mode: 'hash', base: '/base' })
+      expect(u.resolve('/xyz', {a: '123', d: 1}).url).toBe('http://localhost/base#/xyz?a=123&b=c&d=1')
+      expect(u.resolve('/abc', {a: '123', d: 1}, true).url).toBe('http://localhost/base#/abc?a=123&d=1')
+      expect(u.resolve('/def', {}, true).url).toBe('http://localhost/base#/def')
+      expect(u.resolve('/', {}, true).url).toBe('http://localhost/base#/')
+      expect(u.resolve('', {}, true).url).toBe('http://localhost/base#')
+    })
+  })
+})