diff --git a/.gitignore b/.gitignore index c359043..46a03f8 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,4 @@ dist # Build-generated files es -lib \ No newline at end of file +cjs \ No newline at end of file diff --git a/package.json b/package.json index 2777616..fbd8918 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "next-universal-route", - "version": "0.7.0", + "version": "0.7.1", "description": "Universal Next.js Route", - "main": "./lib/index.js", + "main": "./cjs/index.js", "module": "./es/index.js", "modules.root": "./es", - "types": "./lib/index.d.ts", + "types": "./cjs/index.d.ts", "author": "Miloš Brajević ", "repository": "brajevicm/next-universal-route", "license": "MIT", @@ -13,9 +13,9 @@ "lint": "tslint -c tslint.json --project tsconfig.json", "test": "jest --config jest.config.json && codecov", "test:watch": "jest --config jest.config.json --watchAll", - "build": " rimraf ./dist && npm run build:es && npm run build:lib", + "build": " rimraf ./dist && npm run build:es && npm run build:cjs", "build:es": "tsc --module es2015 --target es5 --outDir es", - "build:lib": "tsc --module commonjs --target es5 --outDir lib" + "build:cjs": "tsc --module commonjs --target es5 --outDir cjs" }, "keywords": [ "react", diff --git a/src/lib/Link.tsx b/src/lib/Link.tsx new file mode 100644 index 0000000..3f66ec7 --- /dev/null +++ b/src/lib/Link.tsx @@ -0,0 +1,36 @@ +import React, { Children, ReactChildren } from 'react'; +import NextLink from 'next/link'; + +import { NextRoute } from './NextRoute'; + +type LinkProps = { + href: NextRoute | string; + replace?: boolean; + scroll?: boolean; + shallow?: boolean; + passHref?: boolean; + prefetch?: boolean; + children?: ReactChildren; +}; + +export const Link = (props: LinkProps) => { + const { href, ...rest } = props; + const newHref = typeof href === 'string' ? href : href.toHref(); + + if (typeof href === 'string' || href.isAbsolutePath) { + const child: any = Children.only(props.children); + const { children, ...newRest } = rest; + + if (props.passHref || (child.type === 'a' && !('href' in child.props))) { + return React.cloneElement(child, { href: newHref, ...newRest }); + } + + return ( + + {children} + + ); + } + + return ; +}; diff --git a/src/lib/NextRoute.ts b/src/lib/NextRoute.ts new file mode 100644 index 0000000..aac39d7 --- /dev/null +++ b/src/lib/NextRoute.ts @@ -0,0 +1,52 @@ +import { stringify } from 'qs'; +import { generatePath } from '../utils/generatePath'; +import { isAbsolutePath } from '../utils//isAbsolutePath'; + +export class NextRoute { + public path: string; + public page?: string; + public params?: object; + public queryStringParams?: object; + public query?: object; + public isAbsolutePath: boolean; + + constructor( + path: string, + page?: string, + params?: object, + queryStringParams?: object, + query?: object + ) { + this.path = path; + this.page = page; + this.params = params; + this.queryStringParams = queryStringParams; + this.query = query; + this.isAbsolutePath = isAbsolutePath(path); + } + + public toAs(): string { + if (this.isAbsolutePath) { + return this.path; + } + + const path = generatePath(this.path, this.params); + const queryString = stringify(this.queryStringParams); + + return queryString ? `${path}?${queryString}` : path; + } + + public toHref(): string { + if (this.isAbsolutePath) { + return this.path; + } + + const queryString = stringify({ + ...this.query, + ...this.params, + ...this.queryStringParams + }); + + return queryString ? `${this.page}?${queryString}` : this.page; + } +} diff --git a/src/lib/Route.ts b/src/lib/Route.ts new file mode 100644 index 0000000..0591b8f --- /dev/null +++ b/src/lib/Route.ts @@ -0,0 +1,119 @@ +import pathToRegexp from 'path-to-regexp'; +import { parse } from 'url'; + +import { NextRoute } from './NextRoute'; +import { isFunction } from '../utils/isFunction'; +import { mapValues } from '../utils/mapValues'; +import { formatUrl } from '../utils/formatUrl'; + +export class Route { + public path: string; + public page?: string; + private _query: object; + private urlFormatter?: Function; + private params: object; + private queryStringParams: object; + + constructor(path: string, page?: string, urlFormatter?: Function) { + this.path = path; + this.setPage(`/${page}`); + this.urlFormatter = urlFormatter; + this.params = {}; + this.queryStringParams = {}; + } + + get query() { + return { ...this._query, ...this.queryStringParams }; + } + + public generateUrl(params: object = {}, queryStringParams?: object) { + const newParams = this.formatUrl({ ...this.params, ...params }); + const newQueryStringParams = this.formatUrl({ + ...this.queryStringParams, + ...queryStringParams + }); + + return new NextRoute( + this.path, + this.page, + newParams, + newQueryStringParams, + this._query + ); + } + + public generateFromUrl(url: string, params: object): NextRoute { + const { pathname, query } = this.parseUrl(url); + const keys = []; + const regex = pathToRegexp(this.path, keys); + const values = regex.exec(pathname); + + const newParams = this.getQuery(values.slice(1), keys); + const queryStringParams = { + ...this.queryStringParams, + ...query, + ...params + }; + + return this.generateUrl(newParams, queryStringParams); + } + + public isMatch(url: string) { + const { pathname, query } = this.parseUrl(url); + + const keys = []; + const regex = pathToRegexp(this.path, keys); + const isMatch = regex.test(pathname); + + if (isMatch) { + const values = regex.exec(pathname); + + this._query = { + ...this._query, + ...this.getQuery(values.slice(1), keys) + }; + + this.queryStringParams = query; + } + + return isMatch; + } + + private setPage(url: string): void { + const { pathname, query } = this.parseUrl(url); + + this._query = query; + this.page = pathname; + } + + private formatUrl(params: object): object { + let fn: Function = formatUrl; + + if (isFunction(this.urlFormatter)) { + fn = (string: string) => formatUrl(this.urlFormatter(string)); + } + + return mapValues(params, (param: string | number) => + typeof param === 'string' ? fn(param) : param + ); + } + + private getQuery(values, keys) { + return values.reduce((params, val, i) => { + return { + ...params, + [keys[i].name]: decodeURIComponent(val) + }; + }, {}); + } + + private parseUrl(url: string) { + const parsedUrl = parse(url, true); + const { pathname, query } = parsedUrl; + + return { + pathname, + query + }; + } +} diff --git a/src/lib/Router.ts b/src/lib/Router.ts new file mode 100644 index 0000000..de37e78 --- /dev/null +++ b/src/lib/Router.ts @@ -0,0 +1,46 @@ +import NextRouter, { NextRouter as NextRouterType } from 'next/router'; + +import { Route } from './Route'; +import { NextRoute } from './NextRoute'; + +// import { clone } from './lib/deepClone'; + +// TODO: Find the way to replace Next's Router completely +// const ClonedRouter = ((router: NextRouterType) => { +// const newRouter = clone(router); + +// newRouter.push = (href: NextRoute, options?: object) => +// router.push(href.toHref(), href.toAs(), options); + +// newRouter.prefetch = (href: NextRoute) => router.prefetch(href.toHref()); + +// newRouter.replace = (href: NextRoute, options?: object) => +// router.replace(href.toHref(), href.toAs(), options); + +// return newRouter; +// })(NextRouter); + +export const Router = ((router: NextRouterType) => { + const push = (href: NextRoute, options?: object) => + router.push(href.toHref(), href.toAs(), options); + + const prefetch = (href: NextRoute) => router.prefetch(href.toHref()); + + const replace = (href: NextRoute, options?: object) => + router.replace(href.toHref(), href.toAs(), options); + + const update = (href: Route, params: object) => + push( + href.generateFromUrl( + `${window.location.pathname}${window.location.search}`, + params + ) + ); + + return { + push, + prefetch, + replace, + update + }; +})(NextRouter); diff --git a/src/lib/Routes.ts b/src/lib/Routes.ts new file mode 100644 index 0000000..5e2fc43 --- /dev/null +++ b/src/lib/Routes.ts @@ -0,0 +1,13 @@ +import { Route } from './Route'; + +export class Routes { + private routes: Route[]; + + constructor(routes) { + this.routes = Object.values(routes); + } + + public getRoute(url: string) { + return this.routes.filter(route => route.isMatch(url))[0]; + } +} diff --git a/src/lib/getRequestHandler.ts b/src/lib/getRequestHandler.ts new file mode 100644 index 0000000..b0a89e7 --- /dev/null +++ b/src/lib/getRequestHandler.ts @@ -0,0 +1,16 @@ +import { Routes } from './Routes'; + +export const getRequestHandler = (app, routes) => { + const nextHandler = app.getRequestHandler(); + const router = new Routes(routes); + + return (req, res) => { + const route = router.getRoute(req.url); + + if (route) { + app.render(req, res, route.page, route.query); + } else { + nextHandler(req, res, req.url); + } + }; +};