alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
267 lines (229 loc) • 6.17 kB
text/typescript
import { $inject, Alepha, AlephaError } from "alepha";
import type { PagePrimitive } from "../primitives/$page.ts";
import { ReactBrowserProvider } from "../providers/ReactBrowserProvider.ts";
import {
type AnchorProps,
ReactPageProvider,
type ReactRouterState,
} from "../providers/ReactPageProvider.ts";
export interface RouterPushOptions {
replace?: boolean;
params?: Record<string, string>;
query?: Record<string, string>;
meta?: Record<string, any>;
/**
* Recreate the whole page, ignoring the current state.
*/
force?: boolean;
}
/**
* Friendly browser router API.
*
* Can be safely used server-side, but most methods will be no-op.
*/
export class ReactRouter<T extends object> {
protected readonly alepha = $inject(Alepha);
protected readonly pageApi = $inject(ReactPageProvider);
public get state(): ReactRouterState {
return this.alepha.store.get("alepha.react.router.state")!;
}
public get pages() {
return this.pageApi.getPages();
}
public get concretePages() {
return this.pageApi.getConcretePages();
}
public get browser(): ReactBrowserProvider | undefined {
if (this.alepha.isBrowser()) {
return this.alepha.inject(ReactBrowserProvider);
}
// server-side
return undefined;
}
public isActive(
href: string,
options: {
startWith?: boolean;
} = {},
): boolean {
const current = this.state.url.pathname;
let isActive =
current === href || current === `${href}/` || `${current}/` === href;
if (options.startWith && !isActive) {
isActive = current.startsWith(href);
}
return isActive;
}
public node(
name: keyof VirtualRouter<T> | string,
config: {
params?: Record<string, any>;
query?: Record<string, any>;
} = {},
): any {
// TODO: improve typing (or just remove this method)
const page = this.pageApi.page(name as string);
if (!page.lazy && !page.component) {
return {
...page,
label: page.label ?? page.name,
children: undefined,
};
}
return {
...page,
label: page.label ?? page.name,
href: this.path(name, config),
children: undefined,
};
}
public path(
name: keyof VirtualRouter<T> | string,
config: {
params?: Record<string, any>;
query?: Record<string, any>;
} = {},
): string {
return this.pageApi.pathname(name as string, {
params: {
...this.state?.params,
...config.params,
},
query: config.query,
});
}
/**
* Reload the current page.
* This is equivalent to calling `go()` with the current pathname and search.
*/
public async reload() {
if (!this.browser) {
return;
}
await this.push(this.location.pathname + this.location.search, {
replace: true,
force: true,
});
}
public getURL(): URL {
if (!this.browser) {
return this.state.url;
}
return new URL(this.location.href);
}
public get location(): Location {
if (!this.browser) {
throw new AlephaError("Browser is required");
}
return this.browser.location;
}
public get current(): ReactRouterState {
return this.state;
}
public get pathname(): string {
return this.state.url.pathname;
}
public get query(): Record<string, string> {
const query: Record<string, string> = {};
for (const [key, value] of new URLSearchParams(
this.state.url.search,
).entries()) {
query[key] = String(value);
}
return query;
}
public async back() {
this.browser?.history.back();
}
public async forward() {
this.browser?.history.forward();
}
public async invalidate(props?: Record<string, any>) {
await this.browser?.invalidate(props);
}
public async push(path: string, options?: RouterPushOptions): Promise<void>;
public async push(
path: keyof VirtualRouter<T>,
options?: RouterPushOptions,
): Promise<void>;
public async push(
path: string | keyof VirtualRouter<T>,
options?: RouterPushOptions,
): Promise<void> {
for (const page of this.pages) {
if (page.name === path) {
await this.browser?.push(
this.path(path as keyof VirtualRouter<T>, options),
options,
);
return;
}
}
await this.browser?.push(path as string, options);
}
public anchor(path: string, options?: RouterPushOptions): AnchorProps;
public anchor(
path: keyof VirtualRouter<T>,
options?: RouterPushOptions,
): AnchorProps;
public anchor(
path: string | keyof VirtualRouter<T>,
options: RouterPushOptions = {},
): AnchorProps {
let href = path as string;
for (const page of this.pages) {
if (page.name === path) {
href = this.path(path as keyof VirtualRouter<T>, options);
break;
}
}
return {
href: this.base(href),
onClick: (ev: any) => {
ev.stopPropagation();
ev.preventDefault();
this.push(href, options).catch(console.error);
},
};
}
/**
* Prepend the base URL to the given path.
*/
public base(path: string): string {
const base = import.meta.env?.BASE_URL;
if (!base || base === "/") {
return path;
}
return base + path;
}
/**
* Set query params.
*
* @param record
* @param options
*/
public setQueryParams(
record:
| Record<string, any>
| ((queryParams: Record<string, any>) => Record<string, any>),
options: {
/**
* If true, this will add a new entry to the history stack.
*/
push?: boolean;
} = {},
) {
const func = typeof record === "function" ? record : () => record;
const search = new URLSearchParams(func(this.query)).toString();
const path = search ? `${this.pathname}?${search}` : this.pathname;
const state = this.base(path);
if (options.push) {
window.history.pushState({}, "", state);
} else {
window.history.replaceState({}, "", state);
}
}
}
export type VirtualRouter<T> = {
[K in keyof T as T[K] extends PagePrimitive ? K : never]: T[K];
};