commit aca528128578a1185768d3f67b9158f764592853 Author: defaultkavy Date: Thu Apr 25 20:49:38 2024 +0800 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..affde95 --- /dev/null +++ b/README.md @@ -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 +}) +``` \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..2853cfa --- /dev/null +++ b/index.ts @@ -0,0 +1,20 @@ +import 'elexis'; +import { Router } from './lib/Router'; +declare module 'elexis' { + export namespace $ { + export const routers: Set; + 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, + 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'; \ No newline at end of file diff --git a/lib/Route.ts b/lib/Route.ts new file mode 100644 index 0000000..56193ba --- /dev/null +++ b/lib/Route.ts @@ -0,0 +1,48 @@ +import { $EventMethod, $Node, $EventManager } from "elexis"; +import { $Util } from "elexis/lib/$Util"; + +export class Route { + path: string | PathResolverFn; + builder: (req: RouteRequest) => RouteContent; + constructor(path: Path, builder: ((req: RouteRequest) => RouteContent) | RouteContent) { + this.path = path; + this.builder = builder instanceof Function ? builder : (req: RouteRequest) => builder; + } +} + +type PathParams = Path extends `${infer Segment}/${infer Rest}` + ? Segment extends `${string}:${infer Param}` ? Record & PathParams : PathParams + : Path extends `${string}:${infer Param}` ? Record : {} + +export type PathResolverFn = (path: string) => undefined | string; + +type PathParamResolver

= P extends PathResolverFn +? undefined : PathParams

+ +// type PathResolverRecord

= { +// [key in keyof ReturnType

]: ReturnType

[key] +// } + + +export interface RouteRecord extends $EventMethod {}; +export class RouteRecord { + id: string; + readonly content?: $Node; + events = new $EventManager().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 { + params: PathParamResolver, + record: RouteRecord, + loaded: () => void; +} + +export type RouteContent = $Node | string | void; \ No newline at end of file diff --git a/lib/Router.ts b/lib/Router.ts new file mode 100644 index 0000000..4fc279c --- /dev/null +++ b/lib/Router.ts @@ -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 {}; +export class Router { + routeMap = new Map>(); + recordMap = new Map(); + $view: $View; + static index: number = 0; + static events = new $EventManager().register('pathchange', 'notfound', 'load'); + events = new $EventManager().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>) { + 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, 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).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(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.on(type, callback); return this } + static off(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.off(type, callback); return this } + static once(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}; +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..80a356a --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e73754d --- /dev/null +++ b/tsconfig.json @@ -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 + }, + } + \ No newline at end of file