瀏覽代碼

Easy to use and flexible router for Alpine.js

Shaun Li 2 年之前
當前提交
517b9633b4
共有 7 個文件被更改,包括 332 次插入0 次删除
  1. 24 0
      .gitignore
  2. 21 0
      LICENSE
  3. 27 0
      README.md
  4. 5 0
      builds/cdn.js
  5. 3 0
      builds/module.js
  6. 26 0
      package.json
  7. 226 0
      src/index.js

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Shaun Li
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 27 - 0
README.md

@@ -0,0 +1,27 @@
+# alpinejs-router
+
+Easy to use and flexible router for Alpine.js
+
+## Getting Started
+
+```html
+<a x-link href="/hello/world">Hello World</a>
+
+<a x-link href="/somewhere">Load template</a>
+
+<template x-route="/hello/:name">
+  <div>Say hello to <span x-text="$store.router.params.name"></span></div>
+</template>
+
+<template x-route="/somewhere" template="/somewhere.html"></template>
+```
+
+somewhere.html
+
+```html
+<div x-data="{ open: false }">
+  <button @click="open = ! open">Toggle Content</button>
+
+  <div x-show="open">Content...</div>
+</div>
+```

+ 5 - 0
builds/cdn.js

@@ -0,0 +1,5 @@
+import router from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+  window.Alpine.plugin(router)
+})

+ 3 - 0
builds/module.js

@@ -0,0 +1,3 @@
+import registerRouterPlugin from '../src/index.js'
+
+export default registerRouterPlugin

+ 26 - 0
package.json

@@ -0,0 +1,26 @@
+{
+  "name": "alpinejs-router",
+  "version": "1.0.0",
+  "description": "Easy to use and flexible router for Alpine.js",
+  "main": "dist/module.cjs.js",
+  "module": "dist/module.esm.js",
+  "unpkg": "dist/cdn.min.js",
+  "keywords": [
+    "alpinejs-router",
+    "alpinejs router"
+  ],
+  "scripts": {
+    "unpkg": "esbuild builds/cdn.js --platform=browser --bundle --minify --outfile=dist/cdn.min.js",
+    "cjs": "esbuild builds/module.js --platform=node --bundle --outfile=dist/module.cjs.js",
+    "esm": "esbuild builds/module.js --platform=neutral --bundle --outfile=dist/module.esm.js"
+  },
+  "author": "Shaun Li <shonhen@gmail.com>",
+  "license": "MIT",
+  "homepage": "https://github.com/shaunlee/alpinejs-router",
+  "devDependencies": {
+    "esbuild": "^0.15.9"
+  },
+  "peerDependencies": {
+    "alpinejs": "^3.0.0"
+  }
+}

+ 226 - 0
src/index.js

@@ -0,0 +1,226 @@
+export default function (Alpine) {
+  const state = Alpine.reactive({
+    mode: 'web',
+    base: '',
+    href: location.href,
+    path: '',
+    query: {},
+    pathParams: {}
+  })
+
+  function push (path, options = {}) {
+    if (!path.startsWith(location.origin)) {
+      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 })
+  }
+
+  Alpine.store('router', {
+    init () {
+      Alpine.effect(() => {
+        state.query = (state.href.indexOf('?') > -1)
+          ? Object.fromEntries(new URLSearchParams(state.href.split('?').pop()).entries())
+          : {}
+      })
+
+      window.addEventListener('popstate', () => state.href = location.href)
+    },
+
+    get path () {
+      return state.path
+    },
+    get query () {
+      return state.query
+    },
+    get params () {
+      return state.pathParams[state.path] ?? {}
+    },
+    config (config = {}) {
+      if (config.mode !== 'hash' && config.base && config.base.endsWith('/')) config.base = config.base.slice(0, -1)
+      state.mode = config.mode ?? 'web'
+      state.base = config.base ?? ''
+    },
+    push (...args) {
+      return push(...args)
+    },
+    replace (...args) {
+      return replace(...args)
+    },
+
+    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()
+    }
+  })
+
+  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
+  }
+
+  const routePatterns = {}
+  const templateCaches = {}
+
+  Alpine.directive('route', (el, { modifiers, expression }, { effect, cleanup }) => {
+    if (!modifiers.includes('notfound')) {
+      routePatterns[expression] = buildPattern(expression)
+    }
+
+    function show () {
+      if (el._x_currentIfEl) return el._x_currentIfEl
+
+      const make = () => {
+        const clone = el.content.cloneNode(true).firstElementChild
+
+        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
+        }
+      }
+
+      if (el.content.firstElementChild) {
+        make()
+      } else if (el.hasAttribute('template')) {
+        const url = el.getAttribute('template')
+        if (templateCaches[url]) {
+          el.innerHTML = templateCaches[url]
+          make()
+        } else {
+          fetch(url).then(r => r.text()).then(html => {
+            templateCaches[url] = html
+            el.innerHTML = html
+            make()
+          })
+        }
+      } else {
+        console.error(`Template for '${expression}' is missing`)
+      }
+    }
+
+    function hide () {
+      if (el._x_undoIf) {
+        el._x_undoIf()
+        delete el._x_undoIf
+      }
+    }
+
+    effect(() => {
+      const url = new URL(state.href)
+
+      let [pathname, search] = (state.mode === 'hash')
+        ? url.hash.slice(1).split('?')
+        : [url.pathname.replace(state.base, ''), url.search]
+
+      if (modifiers.includes('notfound')) {
+        // console.time('404')
+        Object.entries(routePatterns).find(e => e[1] instanceof RegExp ? pathname.match(e[1]) : pathname === e[1])
+          ? hide()
+          : show()
+        // console.timeEnd('404')
+        return
+      }
+
+      // console.time('route')
+      const pattern = routePatterns[expression]
+      if (pattern instanceof RegExp) {
+        const m = pathname.match(pattern)
+        if (m) {
+          state.path = pathname
+          state.pathParams = { ...state.pathParams, [pathname]: { ...m.groups } }
+          show()
+        } else {
+          hide()
+        }
+      } else if (pattern === pathname) {
+        state.path = pathname
+        show()
+      } else {
+        hide()
+      }
+      // console.timeEnd('route')
+    })
+
+    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
+
+    function go (e) {
+      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'
+
+      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]
+
+        if (l !== (state.mode !== 'hash' ? state.base : '') + '/' && r.startsWith(l)) {
+          el.classList.add(classes.active)
+        } else {
+          el.classList.remove(classes.active)
+        }
+        if (l === r) {
+          el.classList.add(classes.exactActive)
+        } else {
+          el.classList.remove(classes.exactActive)
+        }
+      })
+    }
+
+    cleanup(() => {
+      el.removeEventListener('click', go)
+    })
+  })
+}