initial
This commit is contained in:
commit
aca5281285
97
README.md
Normal file
97
README.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# @elexis/router
|
||||||
|
Single page app router extension for [ElexisJS](https://github.com/defaultkavy/elexis)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
```
|
||||||
|
npm i @elexis/router
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```ts
|
||||||
|
import 'elexis';
|
||||||
|
import '@elexis/router';
|
||||||
|
import { Router, Route } from '@elexis/router'
|
||||||
|
|
||||||
|
// create new Router with base path '/',
|
||||||
|
const router = new Router('/');
|
||||||
|
|
||||||
|
// append router view element
|
||||||
|
const $app = $('app').content([
|
||||||
|
router.$view
|
||||||
|
])
|
||||||
|
|
||||||
|
const home_page_route = new Route('/', () => {
|
||||||
|
// which this callback function return will be appended
|
||||||
|
// into router view element when path match
|
||||||
|
return $('h1').content('This is a Homepage!')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add home_page_route into router
|
||||||
|
router.addRoute(home_page_route);
|
||||||
|
// Router starting listen location path change
|
||||||
|
router.listen();
|
||||||
|
```
|
||||||
|
> Without `Router.listen()`, Router will do nothing when page loaded.
|
||||||
|
|
||||||
|
## Global $ Methods
|
||||||
|
```ts
|
||||||
|
import 'elexis';
|
||||||
|
import '@elexis/router';
|
||||||
|
|
||||||
|
$.open('/about') // open /about page without load page
|
||||||
|
$.back() // back
|
||||||
|
$.replace('/hello') // replace current page history state with url
|
||||||
|
```
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### Router event: load
|
||||||
|
```ts
|
||||||
|
const gallery_route = new Route('/gallery', ({loaded}) => {
|
||||||
|
|
||||||
|
async function $ImageList() {
|
||||||
|
// fetch images and return elements
|
||||||
|
|
||||||
|
// after all image loaded, using loaded function.
|
||||||
|
// this will trigger load event on anccestor router
|
||||||
|
loaded();
|
||||||
|
}
|
||||||
|
return $('div').content([
|
||||||
|
$ImageList()
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
router.on('load', () => {...})
|
||||||
|
```
|
||||||
|
|
||||||
|
### RouteRecord event: open
|
||||||
|
```ts
|
||||||
|
const viewer_route = new Route('/viewer', ({record}) => {
|
||||||
|
const page_open_count$ = $.state(0);
|
||||||
|
|
||||||
|
// this event will be fire everytime this page opened
|
||||||
|
record.on('open', () => {
|
||||||
|
page_open_count$.set( page_open_count$.value + 1 );
|
||||||
|
})
|
||||||
|
|
||||||
|
return $('div').content(page_open_count$)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Router event: notfound
|
||||||
|
```ts
|
||||||
|
// Route will remove all child of view when path is not exist.
|
||||||
|
// Using preventDefault function to prevent this action.
|
||||||
|
router.on('notfound', ({preventDefault}) => {
|
||||||
|
preventDefault(); // prevent remove all child of view
|
||||||
|
... // do something
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `static` Router event: pathchange
|
||||||
|
```ts
|
||||||
|
// This event fired on location change happened
|
||||||
|
Router.on('pathchange', () => {
|
||||||
|
... // do something
|
||||||
|
})
|
||||||
|
```
|
20
index.ts
Normal file
20
index.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import 'elexis';
|
||||||
|
import { Router } from './lib/Router';
|
||||||
|
declare module 'elexis' {
|
||||||
|
export namespace $ {
|
||||||
|
export const routers: Set<Router>;
|
||||||
|
export function open(path: string | URL | undefined): typeof Router;
|
||||||
|
export function replace(path: string | URL | undefined): typeof Router;
|
||||||
|
export function back(): typeof Router;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign($, {
|
||||||
|
routers: new Set<Router>,
|
||||||
|
open(path: string | URL | undefined) { return Router.open(path) },
|
||||||
|
replace(path: string | URL | undefined) { return Router.replace(path) },
|
||||||
|
back() { return Router.back() }
|
||||||
|
})
|
||||||
|
|
||||||
|
export * from './lib/Route';
|
||||||
|
export * from './lib/Router';
|
48
lib/Route.ts
Normal file
48
lib/Route.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { $EventMethod, $Node, $EventManager } from "elexis";
|
||||||
|
import { $Util } from "elexis/lib/$Util";
|
||||||
|
|
||||||
|
export class Route<Path extends string | PathResolverFn> {
|
||||||
|
path: string | PathResolverFn;
|
||||||
|
builder: (req: RouteRequest<Path>) => RouteContent;
|
||||||
|
constructor(path: Path, builder: ((req: RouteRequest<Path>) => RouteContent) | RouteContent) {
|
||||||
|
this.path = path;
|
||||||
|
this.builder = builder instanceof Function ? builder : (req: RouteRequest<Path>) => builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathParams<Path> = Path extends `${infer Segment}/${infer Rest}`
|
||||||
|
? Segment extends `${string}:${infer Param}` ? Record<Param, string> & PathParams<Rest> : PathParams<Rest>
|
||||||
|
: Path extends `${string}:${infer Param}` ? Record<Param,string> : {}
|
||||||
|
|
||||||
|
export type PathResolverFn = (path: string) => undefined | string;
|
||||||
|
|
||||||
|
type PathParamResolver<P extends PathResolverFn | string> = P extends PathResolverFn
|
||||||
|
? undefined : PathParams<P>
|
||||||
|
|
||||||
|
// type PathResolverRecord<P extends PathResolverFn> = {
|
||||||
|
// [key in keyof ReturnType<P>]: ReturnType<P>[key]
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
export interface RouteRecord extends $EventMethod<RouteRecordEventMap> {};
|
||||||
|
export class RouteRecord {
|
||||||
|
id: string;
|
||||||
|
readonly content?: $Node;
|
||||||
|
events = new $EventManager<RouteRecordEventMap>().register('open', 'load')
|
||||||
|
constructor(id: string) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$Util.mixin(RouteRecord, $EventMethod)
|
||||||
|
export interface RouteRecordEventMap {
|
||||||
|
'open': [{path: string, record: RouteRecord}];
|
||||||
|
'load': [{path: string, record: RouteRecord}];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteRequest<Path extends PathResolverFn | string> {
|
||||||
|
params: PathParamResolver<Path>,
|
||||||
|
record: RouteRecord,
|
||||||
|
loaded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteContent = $Node | string | void;
|
190
lib/Router.ts
Normal file
190
lib/Router.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { $EventMethod, $View, $EventManager, $Text } from "elexis";
|
||||||
|
import { $Util } from "elexis/lib/$Util";
|
||||||
|
import { PathResolverFn, Route, RouteRecord } from "./Route";
|
||||||
|
export interface Router extends $EventMethod<RouterEventMap> {};
|
||||||
|
export class Router {
|
||||||
|
routeMap = new Map<string | PathResolverFn, Route<any>>();
|
||||||
|
recordMap = new Map<string, RouteRecord>();
|
||||||
|
$view: $View;
|
||||||
|
static index: number = 0;
|
||||||
|
static events = new $EventManager<RouterGlobalEventMap>().register('pathchange', 'notfound', 'load');
|
||||||
|
events = new $EventManager<RouterEventMap>().register('notfound', 'load');
|
||||||
|
basePath: string;
|
||||||
|
static currentPath: URL = new URL(location.href);
|
||||||
|
constructor(basePath: string, view?: $View) {
|
||||||
|
this.basePath = basePath;
|
||||||
|
this.$view = view ?? new $View();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**Add route to Router. @example Router.addRoute(new Route('/', 'Hello World')) */
|
||||||
|
addRoute(routes: OrArray<Route<any>>) {
|
||||||
|
routes = $.orArrayResolve(routes);
|
||||||
|
for (const route of routes) this.routeMap.set(route.path, route);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**Start listen to the path change */
|
||||||
|
listen() {
|
||||||
|
if (!history.state || 'index' in history.state === false) {
|
||||||
|
const routeData: RouteData = {index: Router.index, data: {}}
|
||||||
|
history.replaceState(routeData, '')
|
||||||
|
} else {
|
||||||
|
Router.index = history.state.index
|
||||||
|
}
|
||||||
|
addEventListener('popstate', this.popstate)
|
||||||
|
$.routers.add(this);
|
||||||
|
this.resolvePath();
|
||||||
|
Router.events.fire('pathchange', {prevURL: undefined, nextURL: Router.currentPath, navigation: 'Forward'});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**Open URL */
|
||||||
|
static open(url: string | URL | undefined) {
|
||||||
|
if (url === undefined) return this;
|
||||||
|
url = new URL(url);
|
||||||
|
if (url.origin !== location.origin) return this;
|
||||||
|
if (url.href === location.href) return this;
|
||||||
|
const prevPath = Router.currentPath;
|
||||||
|
this.index += 1;
|
||||||
|
const routeData: RouteData = { index: this.index, data: {} };
|
||||||
|
history.pushState(routeData, '', url);
|
||||||
|
Router.currentPath = new URL(location.href);
|
||||||
|
$.routers.forEach(router => router.resolvePath())
|
||||||
|
Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**Back to previous page */
|
||||||
|
static back() {
|
||||||
|
const prevPath = Router.currentPath;
|
||||||
|
history.back();
|
||||||
|
Router.currentPath = new URL(location.href);
|
||||||
|
Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Back'});
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
static replace(url: string | URL | undefined) {
|
||||||
|
if (url === undefined) return this;
|
||||||
|
if (typeof url === 'string' && !url.startsWith(location.origin)) url = location.origin + url;
|
||||||
|
url = new URL(url);
|
||||||
|
if (url.origin !== location.origin) return this;
|
||||||
|
if (url.href === location.href) return this;
|
||||||
|
const prevPath = Router.currentPath;
|
||||||
|
history.replaceState({index: Router.index}, '', url)
|
||||||
|
Router.currentPath = new URL(location.href);
|
||||||
|
$.routers.forEach(router => router.resolvePath(url.pathname));
|
||||||
|
Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStateData(key: string, value: any) {
|
||||||
|
if (history.state.data === undefined) history.state.data = {};
|
||||||
|
history.state.data[key] = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private popstate = (() => {
|
||||||
|
// Forward
|
||||||
|
if (history.state.index > Router.index) { }
|
||||||
|
// Back
|
||||||
|
else if (history.state.index < Router.index) { }
|
||||||
|
const prevPath = Router.currentPath;
|
||||||
|
Router.index = history.state.index;
|
||||||
|
this.resolvePath();
|
||||||
|
Router.currentPath = new URL(location.href);
|
||||||
|
Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: 'Forward'});
|
||||||
|
}).bind(this)
|
||||||
|
|
||||||
|
private resolvePath(path = location.pathname) {
|
||||||
|
if (!path.startsWith(this.basePath)) return;
|
||||||
|
path = path.replace(this.basePath, '/').replace('//', '/');
|
||||||
|
let found = false;
|
||||||
|
const openCached = (pathId: string) => {
|
||||||
|
const record = this.recordMap.get(pathId);
|
||||||
|
if (record) {
|
||||||
|
found = true;
|
||||||
|
if (record.content && !this.$view.contains(record.content)) this.$view.switchView(pathId);
|
||||||
|
record.events.fire('open', {path, record});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const create = (pathId: string, route: Route<any>, data: any) => {
|
||||||
|
const record = new RouteRecord(pathId);
|
||||||
|
let content = route.builder({
|
||||||
|
params: data,
|
||||||
|
record: record,
|
||||||
|
loaded: () => {
|
||||||
|
record.events.fire('load', {path: pathId, record});
|
||||||
|
this.events.fire('load', {path: pathId});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (typeof content === 'string') content = new $Text(content);
|
||||||
|
if (content === undefined) return;
|
||||||
|
(record as Mutable<RouteRecord>).content = content;
|
||||||
|
this.recordMap.set(pathId, record);
|
||||||
|
this.$view.setView(pathId, content).switchView(pathId);
|
||||||
|
record.events.fire('open', {path, record});
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
for (const [pathResolver, route] of this.routeMap.entries()) {
|
||||||
|
// PathResolverFn
|
||||||
|
if (pathResolver instanceof Function) {
|
||||||
|
const routeId = pathResolver(path)
|
||||||
|
if (routeId) { if (!openCached(routeId)) create(routeId, route, undefined) }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// string
|
||||||
|
const [_routeParts, _pathParts] = [pathResolver.split('/').map(p => `/${p}`), path.split('/').map(p => `/${p}`)];
|
||||||
|
_routeParts.shift(); _pathParts.shift();
|
||||||
|
const data = {};
|
||||||
|
let pathString = '';
|
||||||
|
for (let i = 0; i < _pathParts.length; i++) {
|
||||||
|
const [routePart, pathPart] = [_routeParts.at(i), _pathParts.at(i)];
|
||||||
|
if (!routePart || !pathPart) continue;
|
||||||
|
if (routePart === pathPart) {
|
||||||
|
pathString += pathPart;
|
||||||
|
if (routePart === _routeParts.at(-1)) {
|
||||||
|
if (!openCached(pathString)) create(pathString, route, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (routePart.includes(':')) {
|
||||||
|
const [prefix, param] = routePart.split(':');
|
||||||
|
if (!pathPart.startsWith(prefix)) continue;
|
||||||
|
Object.assign(data, {[param]: pathPart.replace(prefix, '')})
|
||||||
|
pathString += pathPart;
|
||||||
|
if (routePart === _routeParts.at(-1)) {
|
||||||
|
if (!openCached(pathString)) create(pathString, route, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
let preventDefaultState = false;
|
||||||
|
const preventDefault = () => preventDefaultState = true;
|
||||||
|
this.events.fire('notfound', {path, preventDefault});
|
||||||
|
if (!preventDefaultState) this.$view.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static on<K extends keyof RouterGlobalEventMap>(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.on(type, callback); return this }
|
||||||
|
static off<K extends keyof RouterGlobalEventMap>(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.off(type, callback); return this }
|
||||||
|
static once<K extends keyof RouterGlobalEventMap>(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.once(type, callback); return this }
|
||||||
|
}
|
||||||
|
$Util.mixin(Router, $EventMethod);
|
||||||
|
interface RouterEventMap {
|
||||||
|
notfound: [{path: string, preventDefault: () => any}];
|
||||||
|
load: [{path: string}];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouterGlobalEventMap {
|
||||||
|
pathchange: [{prevURL?: URL, nextURL: URL, navigation: 'Back' | 'Forward'}];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteData = {
|
||||||
|
index: number;
|
||||||
|
data: {[key: string]: any};
|
||||||
|
}
|
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@elexis/router",
|
||||||
|
"description": "A simple router for ElexisJS",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"author": {
|
||||||
|
"name": "defaultkavy",
|
||||||
|
"email": "defaultkavy@gmail.com",
|
||||||
|
"url": "https://github.com/defaultkavy"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/elexis/router.git"
|
||||||
|
},
|
||||||
|
"module": "index.ts",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/elexis/router/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/elexis/router",
|
||||||
|
"keywords": ["router", "web", "front-end", "lib", "fluent", "framework"],
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"elexis": "^0.1.0"
|
||||||
|
}
|
||||||
|
}
|
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "ES2022"],
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"composite": true,
|
||||||
|
"strict": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"experimentalDecorators": true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user