update: rewrite router extensions, rewrite README
new: $Router, $Route class module
remove: Router, Route class module
change: The way create Router and Route is changed.
This commit is contained in:
2024-10-02 00:58:23 +08:00
parent 1111fc5e66
commit 5ebfab0ef4
8 changed files with 339 additions and 382 deletions

49
lib/$Route.ts Normal file
View File

@@ -0,0 +1,49 @@
import { $Container, $ContainerContentType, $ContainerOptions, $EventManager } from "elexis";
export interface $RouteOptions extends $ContainerOptions {}
export class $Route<Path = PathParams<''>> extends $Container {
#path: $RoutePathType = '';
#builder?: (record: $RouteRecord<Path>) => OrMatrix<$ContainerContentType>;
events = new $EventManager<$RouteEventMap>().register('opened', 'closed')
readonly rendered: boolean = false;
constructor(options?: $RouteOptions) {
super('route', options);
}
path(): $RoutePathType;
path<P extends $RoutePathType>(pathname: P): $Route<P extends string ? PathParams<P> : ''>;
path(pathname?: $RoutePathType): $RoutePathType | $Route<any> { return $.fluent(this, arguments, () => this.#path, () => this.#path = pathname ?? this.#path ) }
builder(builder: (record: $RouteRecord<Path>) => OrMatrix<$ContainerContentType>) {
this.#builder = builder;
return this;
}
render(options: {params: Path}) {
if (this.#builder) this.content(this.#builder({
$route: this,
params: options.params
}));
(this as Mutable<$Route<Path>>).rendered = true;
return this;
}
build(options: {params: Path}) {
return new $Route({dom: this.dom.cloneNode() as HTMLElement}).self(($route) => {
if (this.#builder) $route.builder(this.#builder as any).render({params: options.params as any})
})
}
}
interface $RouteRecord<Path> {
$route: $Route<Path>;
params: Path
}
interface $RouteEventMap {
opened: [];
closed: []
}
export type $RoutePathType = string | string[];
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> : {}

181
lib/$Router.ts Normal file
View File

@@ -0,0 +1,181 @@
import { $EventManager } from "elexis";
import { $Route, $RoutePathType } from "./$Route";
import { $View, $ViewOptions } from "@elexis/view";
export interface $RouterOptions extends $ViewOptions {}
export class $Router extends $View {
#base: string = '';
routes = new Map<$RoutePathType, $Route>();
static routers = new Set<$Router>();
static events = new $EventManager<$RouterEventMap>().register('stateChange')
static navigationDirection: $RouterNavigationDirection
static historyIndex = 0;
static url = new URL(location.href);
private static scrollHistoryKey = `$ROUTER_SCROLL_HISTORY`;
constructor(options?: $RouterOptions) {
super({tagname: 'router', ...options});
$Router.routers.add(this);
}
base(): string;
base(pathname: string): this;
base(pathname?: string) { return $.fluent(this, arguments, () => this.#base, () => { this.#base = pathname ?? this.#base }) }
map(routes: OrMatrix<$Route<any>>) {
routes = $.orArrayResolve(routes);
for (const route of routes) {
if (route instanceof Array) this.map(route);
else {
this.routes.set(route.path(), route);
}
}
this.resolve();
return this;
}
protected resolve(): Promise<$RouterResolveResult> {
return new Promise(resolve => {
if (!location.pathname.startsWith(this.#base)) return resolve($RouterResolveResult.NotMatchBase);
const locationPath = location.pathname.replace(this.#base, '/').replace('//', '/') // /a/b
const locationParts = locationPath.split('/').map(path => `/${path}`);
const find = () => {
const matchedRoutes: {deep: number, $route: $Route, params: {[key: string]: string}, pathId: string}[] = []
for (const [routePathResolve, $route] of this.routes) {
const routePathList = $.orArrayResolve(routePathResolve);
for (const routePath of routePathList) {
let deep = 0, params = {};
if (routePath.startsWith('#')) {
// multiple route path will set the first path as pathId
if (routePath === location.hash) return {deep, $route, params, pathId: routePathList[0]};
else continue;
}
const routeParts = routePath.split('/').map(path => `/${path}`)
if (locationParts.length < routeParts.length) continue;
for (let i = 0; i < routeParts.length; i ++) {
if (routeParts[i].startsWith('/:')) { deep++; Object.assign(params, {[routeParts[i].replace('/:', '')]: locationParts[i].replace('/', '')}); continue; }
else if (routeParts[i] === locationParts[i]) { deep++; continue; }
else { break; }
}
// route path with params will set the locationPath as pathId
matchedRoutes.push({deep, $route, params, pathId: Object.keys(params).length === 0 ? routePathList[0] : locationPath})
}
}
return matchedRoutes.sort((a, b) => b.deep - a.deep).at(0);
}
const $routeData = find();
if (!$routeData) return resolve($RouterResolveResult.NotFound);
const {$route, params, pathId} = $routeData;
if (pathId === this.contentId) return resolve($RouterResolveResult.OK); // current route
this.events.once('rendered', ({nextContent, previousContent}) => {
if (previousContent instanceof $Route) previousContent.events.fire('closed');
if (nextContent instanceof $Route) nextContent.events.fire('opened');
resolve($RouterResolveResult.OK);
});
if (!this.viewCache.get(pathId)) {
const $buildedRoute = $route.build({params});
this.setView(pathId, $buildedRoute);
}
this.switchView(pathId);
})
}
static init() {
if (!history.state || 'index' in history.state === false) {
const state: $RouterState = { index: $Router.historyIndex }
history.replaceState(state, '')
} else {
$Router.historyIndex = history.state.index
}
$Router.navigationDirection = $RouterNavigationDirection.Forward;
$Router.resolve();
window.addEventListener('popstate', () => $Router.popstate());
window.addEventListener('scroll', () => { this.setScrollHistory(this.historyIndex, location.href, document.documentElement.scrollTop) })
history.scrollRestoration = 'manual';
return this;
}
static open(url: string | URL | undefined) {
if (url === undefined) return this;
url = new URL(url);
if (url.href === this.url.href) return this;
this.historyIndex++;
history.pushState($Router.historyState, '', url);
this.stateChange($RouterNavigationDirection.Forward);
$Router.resolve();
return this;
}
static back() {
this.historyIndex--;
history.back()
this.stateChange($RouterNavigationDirection.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;
history.replaceState($Router.historyState, '', url);
this.stateChange($RouterNavigationDirection.Replace);
$Router.resolve();
return this;
}
protected static popstate() {
const direction: $RouterNavigationDirection
= history.state.index > $Router.historyIndex
? $RouterNavigationDirection.Forward
: history.state.index < $Router.historyIndex
? $RouterNavigationDirection.Back
: $RouterNavigationDirection.Replace
$Router.historyIndex = history.state.index;
$Router.stateChange(direction);
$Router.resolve();
}
protected static async resolve() {
await Promise.all([...$Router.routers.values()].map($router => $router.resolve()));
this.scrollRestoration();
}
protected static get historyState() { return { index: $Router.historyIndex, } }
protected static stateChange(direction: $RouterNavigationDirection) {
const beforeURL = this.url;
const afterURL = new URL(location.href);
this.url = afterURL;
$Router.events.fire('stateChange', {beforeURL, afterURL, direction})
$Router.navigationDirection = direction;
}
protected static setScrollHistory(index: number, url: string, value: number) {
const record = this.getScrollHistory()
if (!record) return sessionStorage.setItem(this.scrollHistoryKey, JSON.stringify({[index]: {url, value}}));
record[index] = {url, value};
sessionStorage.setItem(this.scrollHistoryKey, JSON.stringify(record));
}
protected static getScrollHistory() {
const data = sessionStorage.getItem(this.scrollHistoryKey);
if (!data) return undefined;
else return JSON.parse(data) as $RouterScrollHistoryData;
}
protected static scrollRestoration() {
const record = this.getScrollHistory();
if (!record) return;
document.documentElement.scrollTop = record[this.historyIndex]?.value ?? 0;
}
}
enum $RouterResolveResult { OK, NotFound, NotMatchBase }
export enum $RouterNavigationDirection { Forward, Back, Replace }
interface $RouterState { index: number }
export interface $RouterEventMap {
'stateChange': [{beforeURL: URL, afterURL: URL, direction: $RouterNavigationDirection}]
}
interface $RouterScrollHistoryData {[index: number]: {url: string, value: number}}
$Router.init();

View File

@@ -1,48 +0,0 @@
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;

View File

@@ -1,244 +0,0 @@
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);
static readonly SCROLL_HISTORY_KEY = '$router_scroll_history';
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.recoveryScrollPosition();
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.recoveryScrollPosition();
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 recoveryScrollPosition() {
const history = this.getScrollHistory(this.index, location.href);
if (!history) {
document.documentElement.scrollTop = 0;
this.setScrollHistory(this.index, location.href, 0);
}
else document.documentElement.scrollTop = history.scroll;
}
static getScrollHistory(pageIndex: number, href: string) {
const data = this.scrollHistoryData;
if (!data || !data[pageIndex]) return null;
return data[pageIndex].href === href ? data[pageIndex] : null;
}
static setScrollHistory(pageIndex: number, href: string, scroll: number) {
let history = this.scrollHistoryData;
if (!history) {
history = {[pageIndex]: {href, scroll}};
sessionStorage.setItem(this.SCROLL_HISTORY_KEY, JSON.stringify(history));
}
else {
const data = history[pageIndex];
if (data && data.href !== href) {
let i = 0;
while (history[pageIndex + i]) {
delete history[pageIndex + i];
i++;
}
}
history[pageIndex] = {href, scroll}
sessionStorage.setItem(this.SCROLL_HISTORY_KEY, JSON.stringify(history));
}
return history[pageIndex];
}
static get scrollHistoryData() {
const json = sessionStorage.getItem(this.SCROLL_HISTORY_KEY)
if (!json) return null;
return JSON.parse(json) as {[key: number]: RouteScrollHistoryData};
}
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};
}
type RouteScrollHistoryData = {
href: string;
scroll: number;
}
window.addEventListener('scroll', () => {
Router.setScrollHistory(Router.index, location.href, document.documentElement.scrollTop);
})