From 2b26a8d9f95b5968dbda003edc9d540ea95fadd1 Mon Sep 17 00:00:00 2001 From: Ali Mihandoost Date: Sun, 29 Jan 2023 15:53:31 +0330 Subject: [PATCH] feat(router): new api from scratch! --- core/router/package.json | 4 + core/router/src/core.ts | 179 +++++++++++++----------------- core/router/src/index.ts | 1 + core/router/src/type.ts | 146 +++++++++++++----------- core/router/tsconfig.json | 2 + core/type/src/service-response.ts | 2 +- 6 files changed, 171 insertions(+), 163 deletions(-) create mode 100644 core/router/src/index.ts diff --git a/core/router/package.json b/core/router/package.json index 34b7499e8..91b63a77c 100644 --- a/core/router/package.json +++ b/core/router/package.json @@ -33,6 +33,10 @@ "dependencies": { "@alwatr/logger": "^0.28.0", "@alwatr/signal": "^0.28.0", + "@alwatr/math": "^0.28.0", "tslib": "^2.5.0" + }, + "devDependencies": { + "@alwatr/type": "^0.28.0" } } diff --git a/core/router/src/core.ts b/core/router/src/core.ts index 0e2fd0e9b..1c79bb40c 100644 --- a/core/router/src/core.ts +++ b/core/router/src/core.ts @@ -1,119 +1,100 @@ -import {createLogger, globalAlwatr} from '@alwatr/logger'; +import {createLogger} from '@alwatr/logger'; +import {isNumber} from '@alwatr/math'; +import {contextConsumer} from '@alwatr/signal'; +import {ParamValueType} from '@alwatr/type'; -import type {ParamList, RequestRouteParam, Route} from './type.js'; - -globalAlwatr.registeredList.push({ - name: '@alwatr/router', - version: _ALWATR_VERSION_, -}); +import {RouteContext, RoutesConfig} from './type.js'; export const logger = createLogger('alwatr/router'); -/** - * Handle requests of 'route-change' signal. - */ -export function routeSignalProvider(requestParam: RequestRouteParam): Route { - logger.logMethodArgs('routeSignalProvider', {requestParam}); - updateBrowserHistory(requestParam); - return makeRouteObject(requestParam); -} +export const routeContextConsumer = contextConsumer.bind('route-context'); /** - * Update browser history state (history.pushState or history.replaceState). - */ -export function updateBrowserHistory(options: RequestRouteParam): void { - logger.logMethodArgs('updateBrowserHistory', {options}); - - if (options.pushState === false) return; // default is true then undefined means true. - - options.search ??= ''; - options.hash ??= ''; - - if ( - window.location.pathname === options.pathname && - window.location.search === options.search && - window.location.hash === options.hash - ) { + * The result of calling the current route's render() callback base on routesConfig. + * + * alias for `routesConfig.templates[routesConfig.routeId(currentRoute)](currentRoute)` + * + * if the location is app root and `routeId()` return noting then redirect to `home` automatically + * if `routeId()` return noting or render function not defined in the `templates` redirected to `_404` routeId. + * + * Example: + * + * ```ts + * const routeConfig = { + * routeId: (routeContext) => routeContext.sectionList[0]?.toString(), + * templates: { + * 'about': () => html``, + * 'product-list': () => { + * import('./page-product-list.js'); // lazy import + * return html``, + * }, + * 'contact': () => html``, + * 'home': () => html``, + * '_404': () => html``, + * }, + * }; + * + * routerOutlet(routeConfig); + * ``` +*/ +export const routerOutlet = (routesConfig: RoutesConfig): unknown => { + logger.logMethodArgs('routerOutlet', {routesConfig}); + + const routeContext = routeContextConsumer.getValue(); + + if (routeContext == null) { + logger.accident('routerOutlet', 'route_context_undefined', 'Route context not provided yet.'); return; } - const changeState = options.pushState === 'replace' ? 'replaceState' : 'pushState'; - window.history[changeState](null, document.title, options.pathname + options.search + options.hash); -} - -/** - * Make Route from RequestRouteParam. - */ -export function makeRouteObject(requestParam: RequestRouteParam): Route { - logger.logMethodArgs('makeRouteObject', {requestParam}); + const routeId = routesConfig.routeId(routeContext) ?? ''; + const render = routesConfig.templates[routeId]; - requestParam.search ??= ''; - requestParam.hash ??= ''; - - const sectionList = requestParam.pathname - .split('/') - .map(_decodeURIComponent) // decode must be after split because encoded '/' maybe include in values. - .filter((section) => section.trim() !== '') - .map(parseValue); - return { - sectionList, - queryParamList: splitParameterString(requestParam.search.substring(1) /* remove first ? */), - hash: requestParam.hash, - }; -} - -// --- Utils --- - -/** - * decodeURIComponent without throwing error. - */ -export function _decodeURIComponent(val: string): string { try { - return decodeURIComponent(val); + if (typeof render === 'function') { + return render(routeContext); + } + // else + if (routeContext.pathname === '/' && routeId === '') { + return routesConfig.templates.home(routeContext); + } + // else + logger.incident('routerOutlet', 'page_not_found', 'Requested page not defined in routesConfig.templates', { + routeId, + routeContext, + routesConfig, + }); + return routesConfig.templates._404(routeContext); } catch (err) { - return val; + logger.error('routerOutlet', 'render_failed', err); + return routesConfig.templates.home(routeContext); } -} - -/** - * Make query string from {key:val} object - */ -export function joinParameterList(parameterList: ParamList | null | undefined): string { - if (parameterList == null) return ''; - const list: Array = []; - for (const key in parameterList) { - if (Object.prototype.hasOwnProperty.call(parameterList, key)) { - list.push(`${key}=${String(parameterList[key])}`); - } - } - return list.join('&'); -} +}; -/** - * Make {key:val} object from query string - */ -export function splitParameterString(parameterString: string | null | undefined): ParamList { - const parameterList: ParamList = {}; - if (!parameterString) return parameterList; - - parameterString.split('&').forEach((parameter) => { - const parameterArray = parameter.split('='); - parameterList[parameterArray[0]] = parameterArray[1] != null ? parseValue(parameterArray[1]) : ''; - }); - - return parameterList; -} +// ---- /** - * Check type of a value is `number` or not + * Sanitize string value to valid parameters types. */ -export function parseValue(value: string): string | boolean | number { - const trimmedValue = value.trim().toLowerCase(); - if (trimmedValue === '') return value; - if (trimmedValue === 'true' || trimmedValue === 'false') return trimmedValue === 'true'; - const parsedValue = parseFloat(trimmedValue); - // note: `parseFloat('NaN').toString() === 'NaN'` is true, then always check isNaN - if (!isNaN(parsedValue) && parsedValue.toString() === trimmedValue) return parsedValue; +export function sanitizeValue(value?: string | null): ParamValueType { + if (value == null) { + return null; + } + // else + value = value.trim(); + if (value === '') { + return value; + } + // else + const lowerValue = value.toLocaleLowerCase(); + if (lowerValue === 'true' || lowerValue === 'false') { + return lowerValue === 'true'; + } + // else + if (isNumber(value)) { + return +value; + } + // else return value; } diff --git a/core/router/src/index.ts b/core/router/src/index.ts new file mode 100644 index 000000000..2768f9eca --- /dev/null +++ b/core/router/src/index.ts @@ -0,0 +1 @@ +export {routerOutlet, routeContextConsumer} from './core.js'; diff --git a/core/router/src/type.ts b/core/router/src/type.ts index 5bf30dc91..6ffbbe9cf 100644 --- a/core/router/src/type.ts +++ b/core/router/src/type.ts @@ -1,11 +1,34 @@ -export type ParamList = Record; +import {QueryParameters} from '@alwatr/type'; -// @TODO: description -export interface Route { - // href: https://example.com/product/100/book?cart=1&color=white#description - sectionList: Array; // [product, 100, book] - queryParamList: ParamList; // {cart: 1, color: 'white'} - hash: string; // '#description' +/** + * Global route context type. + * + * Sample: + * + * ```js + * { + * href: 'http://example.com:8080/product/100/book?cart=1&color=white#description' + * pathname: '/product/100/book', + * hostname: 'example.com', + * port: 8080, + * origin: http://example.com:8080, + * protocol: 'http', + * sectionList: [product, 100, book], + * queryParamList: {cart: 1, color: 'white'}, + * hash: '#description', + * } + * ``` + */ +export type RouteContext = { + href: string; + pathname: string; + hostname: string; + port: string; + origin: string; + protocol: 'http' | 'https'; + sectionList: Array; + queryParamList: QueryParameters; + hash: string; } // @TODO: description @@ -13,6 +36,7 @@ export interface RequestRouteParam { pathname: string; search?: string; hash?: string; + /** * Update browser history state (history.pushState or history.replaceState). * @@ -44,90 +68,86 @@ export interface InitOptions { } /** - * Routes config for router.outlet. + * Type of `routeConfig.templates` items. + */ +export type TemplateCallback = (routeContext: RouteContext) => unknown; + +/** + * Type of `routeConfig.templates`. + */ +export type RouterTemplates = { + [x: string]: TemplateCallback | undefined; + home: TemplateCallback; + _404: TemplateCallback; +} + +/** + * Routes config for routerOutlet. * - * The `router.outlet` return `list[map(currentRoute)].render(currentRoute)`. + * The `routerOutlet` return `list[map(currentRoute)].render(currentRoute)`. * * Example: * * ```ts - * const routes: routesConfig = { - * map: (route) => route.sectionList[0]?.toString(), - * - * list: { - * 'about': { - * render: () => html``, - * }, - * 'product-list': { - * render: () => { - * import('./page-product-list.js'); // lazy loading page - * html``, - * } - * }, - * 'contact': { - * render: () => html``, - * }, - * - * 'home': { - * render: () => html``, - * }, - * '404': { - * render: () => html``, + * const routeConfig = { + * routeId: (routeContext) => routeContext.sectionList[0]?.toString(), + * templates: { + * 'about': () => html``, + * 'product-list': () => { + * import('./page-product-list.js'); // lazy import + * return html``, * }, + * 'contact': () => html``, + * 'home': () => html``, + * '_404': () => html``, * }, * }; * - * router.outlet(routes); + * routerOutlet(routeConfig); * ``` */ export interface RoutesConfig { /** - * Routes map for finding the target route name (page name). + * Define function to generate routeId (same as pageName) from current routeContext. * - * if the location is app root and `routesConfig.map()` return noting then redirect to home automatically - * if `map` return noting or not found in the list the "404" route will be used. + * if the location is app root and `routeId()` return noting then redirect to `home` automatically + * if `routeId()` return noting or render function not defined in the `templates` redirected to `_404` routeId. * * Example: * * ```ts - * map: (route) => route.sectionList[0]?.toString(), + * router.outlet({ + * routeId: (routeContext) => routeContext.sectionList[0]?.toString(), + * templates: { + * // ... + * }, + * }) * ``` */ - map: (route: Route) => string | undefined; + routeId: (routeContext: RouteContext) => string | undefined; /** - * Define list of routes. + * Define templates of the routes (pages). * * Example: * * ```ts - * list: { - * 'about': { - * render: () => html``, - * }, - * 'product-list': { - * render: () => { - * import('./page-product-list.js'); // lazy loading page - * html``, - * } - * }, - * 'contact': { - * render: () => html``, + * const routeConfig = { + * routeId: (routeContext) => routeContext.sectionList[0]?.toString(), + * templates: { + * 'about': () => html``, + * 'product-list': () => { + * import('./page-product-list.js'); // lazy import + * return html``, + * }, + * 'contact': () => html``, + * 'home': () => html``, + * '_404': () => html``, * }, + * }; * - * 'home': { - * render: () => html``, - * }, - * '404': { - * render: () => html``, - * }, - * }, + * routerOutlet(routeConfig); * ``` */ - list: Record< - string, - { - render: (route: Route) => unknown; - } - >; + templates: RouterTemplates; } diff --git a/core/router/tsconfig.json b/core/router/tsconfig.json index 497929b0f..df45a08b6 100644 --- a/core/router/tsconfig.json +++ b/core/router/tsconfig.json @@ -11,6 +11,8 @@ "exclude": [], "references": [ {"path": "../logger"}, + {"path": "../math"}, + {"path": "../type"}, {"path": "../signal"} ] } diff --git a/core/type/src/service-response.ts b/core/type/src/service-response.ts index d8253895a..100196188 100644 --- a/core/type/src/service-response.ts +++ b/core/type/src/service-response.ts @@ -2,7 +2,7 @@ export type Methods = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | ' export type ParamKeyType = 'string' | 'number' | 'boolean'; export type ParamValueType = string | number | boolean | null; -export type QueryParameters = Record; +export type QueryParameters = Record; export type AlwatrServiceResponseFailed = { ok: false;