Skip to content

Commit

Permalink
Merge pull request #2 from AegisJSProject/patch/updates
Browse files Browse the repository at this point in the history
Misc updates
  • Loading branch information
shgysk8zer0 authored Oct 22, 2024
2 parents debef28 + b598ac8 commit 8b0af8d
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 65 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 11 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
132 changes: 95 additions & 37 deletions router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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.`);
Expand All @@ -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
});
Expand All @@ -90,36 +120,38 @@ 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;
};

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 });
});
}
}

Expand All @@ -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 });
}
}
}
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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));
Expand Down
3 changes: 2 additions & 1 deletion test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"@aegisjsproject/markdown/": "https://unpkg.com/@aegisjsproject/[email protected]/",
"marked": "https://unpkg.com/[email protected]/lib/marked.esm.js",
"marked-highlight": "https://unpkg.com/[email protected]/src/index.js",
"highlight.js/": "https://unpkg.com/@highlightjs/[email protected]/es/"
"highlight.js/": "https://unpkg.com/@highlightjs/[email protected]/es/",
"@view/": "/test/views/"
}
}
</script>
Expand Down
26 changes: 19 additions & 7 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 8b0af8d

Please sign in to comment.