diff --git a/src/router/router.ts b/src/router/router.ts index 874a129..a25dcf8 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -1,6 +1,7 @@ import { compile, match } from 'path-to-regexp' -import type { Route, RouterSchema, RouteWeightsMap } from '~/types' +import type { Route, RouterSchema } from '~/types' import { getLocaleFactory } from '~/utils/locale-utils' +import { sanitizeSchema } from '~/utils/schema-utils' import { StaticRouter } from './static-router' /** @@ -8,7 +9,6 @@ import { StaticRouter } from './static-router' */ export class Router extends StaticRouter { private schema: RouterSchema - private routeWeightsMap: RouteWeightsMap /** * Constructor for the Router class @@ -17,8 +17,7 @@ export class Router extends StaticRouter { constructor(schema: RouterSchema) { super() - this.schema = schema - this.routeWeightsMap = createRouteWeightsMap(schema) + this.schema = sanitizeSchema(schema) } /** @@ -70,19 +69,13 @@ export class Router extends StaticRouter { } /** - * Gets all routes for a given locale, sorted by their dynamic nature + * Gets all routes for a given locale * @param {string} locale - The locale for which to get routes * @returns {Route[]} - The sorted array of routes */ private getLocalizedRoutes(locale: string) { - return ( - this.schema.routes[locale]?.sort((a, b) => { - const weightA = this.routeWeightsMap[a.name] - const weightB = this.routeWeightsMap[b.name] - return weightA - weightB - }) || [] - ) + return this.schema.routes[locale] || [] } /** @@ -150,83 +143,3 @@ export function formatHref(...hrefSegments: string[]): string { .replaceAll('%2F', '/') return href.startsWith('/') ? href : `/${href}` } - -/** - * Checks if given route segment is static - * @param {string} segment - The route segment - * @returns {boolean} - Whether the segment is static - */ - -export function isStaticRouteSegment(segment: string): boolean { - return !segment.includes('[') -} - -/** - * Checks if given route segment is catch-all - * @param {string} segment - The route segment - * @returns {boolean} - Whether the segment is catch-all - */ - -export function isCatchAllRouteSegment(segment: string): boolean { - return segment.includes('...') -} - -/** - * Checks if given route segment is dynamic - * @param {string} segment - The route segment - * @returns {boolean} - Whether the segment is dynamic - */ - -export function isDynamicRouteSegment(segment: string): boolean { - return segment.includes('[') && !isCatchAllRouteSegment(segment) -} - -/** - * Gets weight of a route segment based on its nature - * @param {string} segment - The route segment - * @returns {number} - The weight of the segment - */ - -export function getRouteSegmentWeight(segment: string): number { - if (isStaticRouteSegment(segment)) { - return 1 - } else if (isDynamicRouteSegment(segment)) { - return 2 - } else if (isCatchAllRouteSegment(segment)) { - return 3 - } - return 0 -} - -/** - * Computes weight of a route based on its segments nature - * @param {Route} route - The route to compute weight for - * @returns {number} - The weight of the route - */ - -export function computeRouteWeight(route: Route): number { - const segments = route.name.split('/').filter((segment) => segment.length > 0) // filter out empty segments - let weight = '0.' - for (const segment of segments) { - const segmentWeight = getRouteSegmentWeight(segment) - weight += segmentWeight - } - return parseFloat(weight) -} - -/** - * Creates a map of route weights based on the routing schema - * @param {RouterSchema} schema - The routing schema - * @returns {RouteWeightsMap} - The map of route weights - */ - -export function createRouteWeightsMap(schema: RouterSchema): RouteWeightsMap { - const routeWeightsMap: RouteWeightsMap = {} - const routes = schema.routes[schema.defaultLocale] // No need to create a map for each locale as the names are the same - if (routes) { - for (const route of routes) { - routeWeightsMap[route.name] = computeRouteWeight(route) - } - } - return routeWeightsMap -} diff --git a/src/types.ts b/src/types.ts index ee2dc61..9c2ca5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,4 +9,4 @@ export type RouterSchema = { defaultLocale: string } -export type RouteWeightsMap = Record<`/${string}`, number> +export type RoutesPrioritiesMap = Record<`/${string}`, number> diff --git a/src/utils/route-utils.ts b/src/utils/route-utils.ts new file mode 100644 index 0000000..4da2e1a --- /dev/null +++ b/src/utils/route-utils.ts @@ -0,0 +1,83 @@ +/** + * Checks if given route segment is static + * @param {string} segment - The route segment + * @returns {boolean} - Whether the segment is static + */ + +import type { Route, RouterSchema, RoutesPrioritiesMap } from '~/types' + +export function isStaticRouteSegment(segment: string): boolean { + return !segment.includes('[') +} + +/** + * Checks if given route segment is catch-all + * @param {string} segment - The route segment + * @returns {boolean} - Whether the segment is catch-all + */ + +export function isCatchAllRouteSegment(segment: string): boolean { + return segment.includes('...') +} + +/** + * Checks if given route segment is dynamic + * @param {string} segment - The route segment + * @returns {boolean} - Whether the segment is dynamic + */ + +export function isDynamicRouteSegment(segment: string): boolean { + return segment.includes('[') && !isCatchAllRouteSegment(segment) +} + +/** + * Gets priority of a route segment based on its nature + * @param {string} segment - The route segment + * @returns {number} - The priority of the segment + */ + +export function getRouteSegmentPriority(segment: string): number { + if (isStaticRouteSegment(segment)) { + return 1 + } else if (isDynamicRouteSegment(segment)) { + return 2 + } else if (isCatchAllRouteSegment(segment)) { + return 3 + } + return 0 +} + +/** + * Computes priority of a route based on its segments nature + * @param {Route} route - The route to compute priority for + * @returns {number} - The priority of the route + */ + +export function computeRoutePriority(route: Route): number { + const segments = route.name.split('/').filter((segment) => segment.length > 0) // filter out empty segments + let priority = '0.' + for (const segment of segments) { + const segmentPriority = getRouteSegmentPriority(segment) + priority += segmentPriority + } + return parseFloat(priority) +} + +/** + * Creates a map of route priorities based on the routing schema + * @param {RouterSchema} schema - The routing schema + * @returns {RoutesPrioritiesMap} - The map of route priorities + */ + +export function createRoutesPrioritiesMap( + schema: RouterSchema +): RoutesPrioritiesMap { + const routesPrioritiesMap: RoutesPrioritiesMap = {} + const routes = schema.routes[schema.defaultLocale] || [] // No need to create a map for each locale as the names are the same + + for (const route of routes) { + routesPrioritiesMap[route.name] = computeRoutePriority(route) + } + + return routesPrioritiesMap +} diff --git a/src/utils/schema-utils.ts b/src/utils/schema-utils.ts new file mode 100644 index 0000000..87e0efe --- /dev/null +++ b/src/utils/schema-utils.ts @@ -0,0 +1,15 @@ +import type { RouterSchema } from '~/types' +import { createRoutesPrioritiesMap } from './route-utils' + +export function sanitizeSchema(schema: RouterSchema) { + const routesPrioritiesMap = createRoutesPrioritiesMap(schema) + Object.keys(schema.routes).forEach((locale) => { + schema.routes[locale] = schema.routes[locale].sort((a, b) => { + const priorityA = routesPrioritiesMap[a.name] + const priorityB = routesPrioritiesMap[b.name] + return priorityA - priorityB + }) + }) + + return schema +} diff --git a/src/utils/utils.test.ts b/src/utils/utils.test.ts index ff738ce..00038a8 100644 --- a/src/utils/utils.test.ts +++ b/src/utils/utils.test.ts @@ -1,5 +1,15 @@ +import type { RouterSchema } from '~/types' import { trimLeadingSlash } from './path-utils' import { isLayout, isNotFound, isPage } from './rewrite-utils' +import { + computeRoutePriority, + createRoutesPrioritiesMap, + getRouteSegmentPriority, + isCatchAllRouteSegment, + isDynamicRouteSegment, + isStaticRouteSegment, +} from './route-utils' +import { sanitizeSchema } from './schema-utils' describe('trimLeadingSlash', () => { const testCases = [ @@ -65,3 +75,139 @@ describe('rewrite-utils', () => { }) }) }) + +describe('route-utils', () => { + describe('isStaticRouteSegment', () => { + const testCases = [ + ['static', true], + ['[dynamic]', false], + ['[...catchAll]', false], + ['[[...optionalCatchAll]]', false], + ] as const + + test.each(testCases)('given %o, returns %s', (input, expectedResult) => { + expect(isStaticRouteSegment(input)).toEqual(expectedResult) + }) + }) + + describe('isDynamicRouteSegment', () => { + const testCases = [ + ['static', false], + ['[dynamic]', true], + ['[...catchAll]', false], + ['[[...optionalCatchAll]]', false], + ] as const + + test.each(testCases)('given %o, returns %s', (input, expectedResult) => { + expect(isDynamicRouteSegment(input)).toEqual(expectedResult) + }) + }) + + describe('isCatchAllRouteSegment', () => { + const testCases = [ + ['static', false], + ['[dynamic]', false], + ['[...catchAll]', true], + ['[[...optionalCatchAll]]', true], + ] as const + + test.each(testCases)('given %o, returns %s', (input, expectedResult) => { + expect(isCatchAllRouteSegment(input)).toEqual(expectedResult) + }) + }) + + describe('getRouteSegmentPriority', () => { + const testCases = [ + ['static', 1], + ['[dynamic]', 2], + ['[...catchAll]', 3], + ['[[...optionalCatchAll]]', 3], + ] as const + + test.each(testCases)('given %o, returns %s', (input, expectedResult) => { + expect(getRouteSegmentPriority(input)).toEqual(expectedResult) + }) + }) + + describe('computeRoutePriority', () => { + const testCases = [ + [{ name: '/only-static/nested-path' }, 0.11], + [{ name: '/static/[withDynamic]' }, 0.12], + [{ name: '/static/[...withCatchAll]' }, 0.13], + [{ name: '/[dynamic]/with-static' }, 0.21], + [{ name: '/[dynamic]/[withDynamic]' }, 0.22], + [{ name: '/[dynamic]/[...catchAll]' }, 0.23], + [{ name: '/[...withCatchAll]' }, 0.3], + ] as const + + test.each(testCases)('given %o, returns %s', (input, expectedResult) => { + expect(computeRoutePriority({ name: input.name, href: '/' })).toEqual( + expectedResult + ) + }) + }) + + test('createRoutesPrioritiesMap', () => { + const inputSchema: RouterSchema = { + defaultLocale: 'en', + locales: ['en'], + routes: { + en: [ + { name: '/static', href: '/static' }, + { name: '/static/[dynamic]', href: '/static/:dynamic' }, + { name: '/static/[...catchAll]', href: '/static/:catchAll+' }, + { name: '/[dynamic]', href: '/:dynamic' }, + { name: '/[...catchAll]', href: '/:catchAll+' }, + { name: '/[[...optionalCatchAll]]', href: '/:optionalCatchAll*' }, + ], + }, + } + + const expectedResult = { + '/static': 0.1, + '/static/[dynamic]': 0.12, + '/static/[...catchAll]': 0.13, + '/[dynamic]': 0.2, + '/[...catchAll]': 0.3, + '/[[...optionalCatchAll]]': 0.3, + } + + expect(createRoutesPrioritiesMap(inputSchema)).toEqual(expectedResult) + }) +}) + +describe('schema-utils', () => { + test('sanitizeSchema', () => { + const inputSchema: RouterSchema = { + defaultLocale: 'en', + locales: ['en'], + routes: { + en: [ + { name: '/[dynamic]', href: '/:dynamic' }, + { name: '/[...catchAll]', href: '/:catchAll+' }, + { name: '/static', href: '/static' }, + { name: '/[[...optionalCatchAll]]', href: '/:optionalCatchAll*' }, + { name: '/static/[...catchAll]', href: '/static/:catchAll+' }, + { name: '/static/[dynamic]', href: '/static/:dynamic' }, + ], + }, + } + + const expectedResult: RouterSchema = { + defaultLocale: 'en', + locales: ['en'], + routes: { + en: [ + { name: '/static', href: '/static' }, + { name: '/static/[dynamic]', href: '/static/:dynamic' }, + { name: '/static/[...catchAll]', href: '/static/:catchAll+' }, + { name: '/[dynamic]', href: '/:dynamic' }, + { name: '/[...catchAll]', href: '/:catchAll+' }, + { name: '/[[...optionalCatchAll]]', href: '/:optionalCatchAll*' }, + ], + }, + } + + expect(sanitizeSchema(inputSchema)).toEqual(expectedResult) + }) +})