From b598ac82d9096c55a702321a2dd43956d1f72225 Mon Sep 17 00:00:00 2001 From: Chris Zuber Date: Mon, 21 Oct 2024 18:32:55 -0700 Subject: [PATCH] Misc updates ### Added - Add support for `method` and `formData` in navigation and module handling - Add form submit handler - Add `navigate` event, which can have default prevented (`event.preventDefault()`) to cancel navigation ### Changed - Update `@aegisjsproject/state` ### Fixed - Fix typo --- CHANGELOG.md | 13 +++++ package-lock.json | 22 +++---- package.json | 6 +- router.js | 132 ++++++++++++++++++++++++++++++------------ test/index.html | 3 +- test/index.js | 26 ++++++--- test/views/404.js | 4 +- test/views/product.js | 1 + test/views/search.js | 19 ++++-- 9 files changed, 161 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8989cb1..31c5e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v1.0.1] - 2024-10-21 + +### Added +- Add support for `method` and `formData` in navigation and module handling +- Add form submit handler +- Add `navigate` event, which can have default prevented (`event.preventDefault()`) to cancel navigation + +### Changed +- Update `@aegisjsproject/state` + +### Fixed +- Fix typo + ## [v1.0.0] - 2024-10-13 Initial Release diff --git a/package-lock.json b/package-lock.json index 86ad0be..21d9b9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aegisjsproject/router", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@aegisjsproject/router", - "version": "1.0.0", + "version": "1.0.1", "funding": [ { "type": "librepay", @@ -18,10 +18,12 @@ } ], "license": "MIT", + "dependencies": { + "@aegisjsproject/state": "^1.0.1" + }, "devDependencies": { "@aegisjsproject/component": "^0.1.4", "@aegisjsproject/core": "^0.2.15", - "@aegisjsproject/state": "^1.0.0", "@rollup/plugin-node-resolve": "^15.3.0", "@shgysk8zer0/eslint-config": "^1.0.1", "eslint": "^9.12.0", @@ -121,10 +123,9 @@ } }, "node_modules/@aegisjsproject/state": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@aegisjsproject/state/-/state-1.0.0.tgz", - "integrity": "sha512-xCGVKy7yVZjfUdSVKVLu11CNhdE0bLMOU4fO9OCkryL95dKDVuvVy9KR+C5fj2VBq0ZWhi1h/lmSqYm4Efo+Yg==", - "dev": true, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@aegisjsproject/state/-/state-1.0.1.tgz", + "integrity": "sha512-x2NxyGyiekvAbdQtUpCI6zk6kr5Qo96B9j+R7CR8pwm5Ky7MMCqAvfE+3QCEhn6+LXeL/O0Q1OF9f8xu5/qEqw==", "funding": [ { "type": "librepay", @@ -2081,10 +2082,9 @@ "dev": true }, "@aegisjsproject/state": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@aegisjsproject/state/-/state-1.0.0.tgz", - "integrity": "sha512-xCGVKy7yVZjfUdSVKVLu11CNhdE0bLMOU4fO9OCkryL95dKDVuvVy9KR+C5fj2VBq0ZWhi1h/lmSqYm4Efo+Yg==", - "dev": true + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@aegisjsproject/state/-/state-1.0.1.tgz", + "integrity": "sha512-x2NxyGyiekvAbdQtUpCI6zk6kr5Qo96B9j+R7CR8pwm5Ky7MMCqAvfE+3QCEhn6+LXeL/O0Q1OF9f8xu5/qEqw==" }, "@aegisjsproject/styles": { "version": "0.2.3", diff --git a/package.json b/package.json index 27e8c97..0a60d3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aegisjsproject/router", - "version": "1.0.0", + "version": "1.0.1", "description": "A simple but powerful router module", "keywords": [], "type": "module", @@ -77,10 +77,12 @@ "url": "https://github.com/AegisJSProject/router/issues" }, "homepage": "https://github.com/AegisJSProject/router#readme", + "dependencies": { + "@aegisjsproject/state": "^1.0.1" + }, "devDependencies": { "@aegisjsproject/component": "^0.1.4", "@aegisjsproject/core": "^0.2.15", - "@aegisjsproject/state": "^1.0.0", "@rollup/plugin-node-resolve": "^15.3.0", "@shgysk8zer0/eslint-config": "^1.0.1", "eslint": "^9.12.0", diff --git a/router.js b/router.js index e5faaae..15c57cf 100644 --- a/router.js +++ b/router.js @@ -2,9 +2,12 @@ import { getStateObj, diffState, notifyStateChange } from '@aegisjsproject/state const registry = new Map(); const matchSymbol = Symbol('matchResult'); +const NO_BODY_METHODS = ['GET', 'HEAD', 'DELETE', 'OPTIONS']; let rootEl = document.getElementById('root') ?? document.body; -const mutObserver = new MutationObserver(entries => entries.forEach(entry => interceptNav(entry.target))); +const mutObserver = new MutationObserver(entries => { + entries.forEach(entry => interceptNav(entry.target)); +}); async function _popstateHandler(event) { const diff = diffState(event.state); @@ -14,16 +17,40 @@ async function _popstateHandler(event) { }; function _interceptLinkClick(event) { - if (event.currentTarget.href.startsWith(location.origin)) { + if (event.isTrusted && event.currentTarget.href.startsWith(location.origin)) { event.preventDefault(); navigate(event.currentTarget.href); } }; -async function _getHTML(url) { +function _interceptFormSubmit(event) { + if (event.isTrusted && event.target.action.startsWith(location.origin)) { + event.preventDefault(); + const { method, action } = event.target; + const formData = new FormData(event.target); + + if (NO_BODY_METHODS.includes(method.toUpperCase())) { + const url = new URL(action); + const params = new URLSearchParams(formData); + + for (const [key, val] of formData.entries()) { + url.searchParams.append(key, val); + } + + navigate(url, { method }); + } else { + navigate(action, {}, { method, formData }); + } + } +} + +async function _getHTML(url, { signal, method = 'GET', body } = {}) { const resp = await fetch(url, { + method, + body: NO_BODY_METHODS.includes(method.toUpperCase()) ? null : body, headers: { 'Accept': 'text/html' }, referrerPolicy: 'no-referrer', + signal, }); const html = await resp.text(); @@ -62,7 +89,9 @@ async function _handleModule(moduleSrc, args = {}) { const state = getStateObj(); const timestamp = performance.now(); - if (module instanceof Error) { + if (args.signal instanceof AbortSignal && args.signal.aborted) { + return args.signal.reason.message; + } else if (module instanceof Error) { return module.message; } else if (! ('default' in module)) { return new Error(`${moduleSrc} has no default export.`); @@ -75,11 +104,12 @@ async function _handleModule(moduleSrc, args = {}) { } module.default[matchSymbol] = args; + return module.default; } else if (module.default instanceof Function) { return await module.default({ - state, url, + state, timestamp, ...args }); @@ -90,20 +120,16 @@ async function _handleModule(moduleSrc, args = {}) { } } -let page404 = ({ url = location.href }) => { +let view404 = ({ url = location.href, method = 'GET' }) => { const div = document.createElement('div'); const p = document.createElement('p'); const a = document.createElement('a'); - p.textContent = `${url} [404 Not Found]`; + p.textContent = `${method.toUpperCase()} ${url.input} [404 Not Found]`; a.href = document.baseURI; a.textContent = 'Go Home'; - a.addEventListener('click', event => { - event.preventDefault(); - navigate(event.currentTarget.href); - }, { once: true }); - + a.addEventListener('click', _interceptLinkClick); div.append(p, a); return div; @@ -111,15 +137,21 @@ let page404 = ({ url = location.href }) => { export const findPath = input => registry.keys().find(pattern => pattern.test(input)); -export const set404 = path => page404 = path; +export const set404 = path => view404 = path; export function interceptNav(target = document.body, { signal } = {}) { - if (target.tagName === 'A') { + if (target.tagName === 'A' && target.href.startsWith(location.origin)) { entry.target.addEventListener('click', _interceptLinkClick, { signal, passive: false }); + } else if (target.tagName === 'FORM' && target.action.startsWith(location.origin)) { + entry.target.addEventListener('submit', _interceptFormSubmit, { signal, passive: false }); } else { target.querySelectorAll('a[href]').forEach(el => { el.addEventListener('click', _interceptLinkClick, { passive: false, signal }); }); + + target.querySelectorAll('form').forEach(el => { + el.addEventListener('submit', _interceptFormSubmit, { passive: false, signal }); + }); } } @@ -143,7 +175,7 @@ export function observeLinksOn(target = document.body, { signal } = {}) { mutObserver.observe(target, { childList: true, subtree: true }); if (signal instanceof AbortSignal) { - signal.addEventListener('abort', () => mutObserver.disconnect()); + signal.addEventListener('abort', () => mutObserver.disconnect(), { once: true }); } } } @@ -160,44 +192,70 @@ export function registerPath(path, moduleSrc) { } } -export async function getModule(input = location) { +export async function getModule(input = location, { signal, method = 'GET', formData = new FormData() } = {}) { const timestamp = performance.now(); if (input === null) { throw new Error('Invalid path.'); } else if (! (input instanceof URL)) { - return await getModule(URL.parse(input, document.baseURI)); + return await getModule(URL.parse(input, document.baseURI), { signal, method, formData }); } else { const match = findPath(input); if (match instanceof URLPattern) { - return await _handleModule(registry.get(match), match.exec(input)); - } else if (typeof page404 === 'string') { - return await _handleModule(page404, {}); - } else if (page404 instanceof Function) { - _updatePage(page404({ timestamp, state: getStateObj(), url: new URL(location.href) })) + return await _handleModule(registry.get(match), { url: { input, matches: match.exec(input) }, signal, method, formData }); + } else if (typeof view404 === 'string') { + return await _handleModule(view404, { url: { input, matches: null, }, signal, method, formData }); + } else if (view404 instanceof Function) { + _updatePage(view404({ timestamp, state: getStateObj(), url: { input, matches: null }, signal, method, formData })) } else { - return await _getHTML(input); + return await _getHTML(input, { method, signal, body: formData }); } } } -export async function navigate(url, state = getStateObj()) { +export async function navigate(url, newState = getStateObj(), { + signal, + method = 'GET', + formData, +} = {}) { if (url === null) { throw new TypeError('URL cannot be null.'); + } else if (signal instanceof AbortSignal && signal.aborted) { + throw signal.reason; } else if (! (url instanceof URL)) { - await navigate(URL.parse(url, document.baseURI), state); - } else if (url.href !== location.href) { - try { - rootEl.dispatchEvent(new Event('beforenavigate')); - history.pushState(state, '', url); - await notifyStateChange(); - const content = await getModule(url); + return await navigate(URL.parse(url, document.baseURI), newState, { signal, method, formData }); + } else if (formData instanceof FormData && NO_BODY_METHODS.includes(method.toUpperCase())) { + const params = new URLSearchParams(formData); - _updatePage(content); - rootEl.dispatchEvent(new Event('afternavigate')); + for (const [key, val] of params) { + url.searchParams.append(key, val); + } - return content; + return await navigate(url, newState, { signal, method }); + } else if (url.href !== location.href) { + try { + const oldState = getStateObj(); + const diff = diffState(newState, oldState); + const navigate = new CustomEvent('navigate', { + bubbles: true, + cancelable: true, + detail: { newState, oldState, destination: url.href, method, formData }, + }); + + rootEl.dispatchEvent(navigate); + + if (! navigate.defaultPrevented) { + history.pushState(newState, '', url); + await notifyStateChange(diff); + const content = await getModule(url, { signal, method, formData }); + + _updatePage(content); + + return content; + } else { + return null; + } } catch(err) { back(); reportError(err); @@ -231,7 +289,7 @@ export function removeListener() { export async function init(paths, { preload = false, - intceptRoot = document.body, + inteceptRoot = document.body, baseURL = document.baseURI, notFound, rootNode, @@ -253,8 +311,8 @@ export async function init(paths, { setRoot(rootNode); } - if (intceptRoot instanceof HTMLElement || typeof intceptRoot === 'string') { - observeLinksOn(intceptRoot, { signal }); + if (inteceptRoot instanceof HTMLElement || typeof inteceptRoot === 'string') { + observeLinksOn(inteceptRoot, { signal }); } const content = await getModule(new URL(location.href)); diff --git a/test/index.html b/test/index.html index 2683d1e..f244e57 100644 --- a/test/index.html +++ b/test/index.html @@ -16,7 +16,8 @@ "@aegisjsproject/markdown/": "https://unpkg.com/@aegisjsproject/markdown@0.1.3/", "marked": "https://unpkg.com/marked@14.1.2/lib/marked.esm.js", "marked-highlight": "https://unpkg.com/marked-highlight@2.1.4/src/index.js", - "highlight.js/": "https://unpkg.com/@highlightjs/cdn-assets@11.10.0/es/" + "highlight.js/": "https://unpkg.com/@highlightjs/cdn-assets@11.10.0/es/", + "@view/": "/test/views/" } } diff --git a/test/index.js b/test/index.js index 5486459..ea7ea91 100644 --- a/test/index.js +++ b/test/index.js @@ -3,19 +3,31 @@ import { init, navigate, back, forward, reload } from '@aegisjsproject/router'; globalThis.controller = new AbortController(); init({ - '/product/:productId': '@aegisjsproject/router/test/views/product.js', - '/page/markdown': '@aegisjsproject/router/test/views/markdown.js', - '/test/': '@aegisjsproject/router/test/views/home.js', - '/search?q=:query': '@aegisjsproject/router/test/views/search.js', - '/img': '@aegisjsproject/router/test/views/img.js', + '/product/:productId': '@view/product.js', + '/page/markdown': '@view/markdown.js', + '/test/': '@view/home.js', + '/search?q=:query': '@view/search.js', + '/img': '@view/img.js', }, { preload: true, - notFound: '@aegisjsproject/router/test/views/404.js', + notFound: '@view/404.js', rootNode: '#root', - intceptRoot: document.body, + inteceptRoot: document.body, signal: controller.signal, }); +globalThis.base64ToBlob = function(input) { + const pattern = new URLPattern({ protocol: 'data:', pathname: ':type;base64,:data' }); + const { type, data } = pattern.exec(input)?.pathname?.groups ?? {}; + + if (typeof type === 'string' && typeof data === 'string') { + const bytes = Uint8Array.fromBase64(data); + return new Blob([bytes], { type }); + } else { + return null; + } +} + document.querySelectorAll('[data-link]').forEach(el => { el.addEventListener('click', ({ currentTarget }) => { const { link, ...state } = currentTarget.dataset; diff --git a/test/views/404.js b/test/views/404.js index bc115a4..5b6ff50 100644 --- a/test/views/404.js +++ b/test/views/404.js @@ -1,7 +1,7 @@ -export default ({ url }) => { +export default ({ url, method }) => { const div = document.createElement('div'); const h1 = document.createElement('h1'); - h1.textContent = `${url} [404 Not Found]`; + h1.textContent = `${method} ${url.input.href} [404 Not Found]`; const a = document.createElement('a'); a.href = document.baseURI; a.textContent = 'Home'; diff --git a/test/views/product.js b/test/views/product.js index 5135b0d..0e549b4 100644 --- a/test/views/product.js +++ b/test/views/product.js @@ -3,6 +3,7 @@ class ProductView extends HTMLElement { constructor(params) { super(); + console.log(params); this.#shadow = this.attachShadow({ mode: 'closed' }); const pre = document.createElement('pre'); const code = document.createElement('code'); diff --git a/test/views/search.js b/test/views/search.js index ebbfc1b..08fe730 100644 --- a/test/views/search.js +++ b/test/views/search.js @@ -1,7 +1,16 @@ +import { html } from '@aegisjsproject/core/parsers/html.js'; + export default (params) => { - const pre = document.createElement('pre'); - const code = document.createElement('code'); - code.textContent = JSON.stringify(params, null, 4); - pre.append(code); - return pre; + const frag = html` + +
${JSON.stringify(params, null, 4)}
+ `; + + // Cannot set `action` in parsing HTML because of sanitizer + frag.getElementById('search').action = '/search'; + return frag; };