--- /dev/null
+import { describe, expect, it } from 'vitest'
+import {
+ MatcherPatternPathStatic,
+ MatcherPatternPathStar,
+} from './matcher-pattern'
+
+describe('MatcherPatternPathStatic', () => {
+ describe('match()', () => {
+ it('matches exact path', () => {
+ const pattern = new MatcherPatternPathStatic('/team')
+ expect(pattern.match('/team')).toEqual({})
+ })
+
+ it('matches root path', () => {
+ const pattern = new MatcherPatternPathStatic('/')
+ expect(pattern.match('/')).toEqual({})
+ })
+
+ it('throws for non-matching path', () => {
+ const pattern = new MatcherPatternPathStatic('/team')
+ expect(() => pattern.match('/users')).toThrow()
+ expect(() => pattern.match('/')).toThrow()
+ })
+
+ it('is case insensitive', () => {
+ const pattern = new MatcherPatternPathStatic('/Team')
+ expect(pattern.match('/team')).toEqual({})
+ expect(pattern.match('/TEAM')).toEqual({})
+ expect(pattern.match('/tEAm')).toEqual({})
+ })
+ })
+
+ describe('build()', () => {
+ it('returns the original path', () => {
+ const pattern = new MatcherPatternPathStatic('/team')
+ expect(pattern.build()).toBe('/team')
+ })
+
+ it('returns root path', () => {
+ const pattern = new MatcherPatternPathStatic('/')
+ expect(pattern.build()).toBe('/')
+ })
+ })
+})
+
+describe('MatcherPatternPathStar', () => {
+ describe('match()', () => {
+ it('matches everything by default', () => {
+ const pattern = new MatcherPatternPathStar()
+ expect(pattern.match('/anything')).toEqual({ pathMatch: '/anything' })
+ expect(pattern.match('/')).toEqual({ pathMatch: '/' })
+ })
+
+ it('can match with a prefix', () => {
+ const pattern = new MatcherPatternPathStar('/team')
+ expect(pattern.match('/team')).toEqual({ pathMatch: '' })
+ expect(pattern.match('/team/')).toEqual({ pathMatch: '/' })
+ expect(pattern.match('/team/123')).toEqual({ pathMatch: '/123' })
+ expect(pattern.match('/team/123/456')).toEqual({ pathMatch: '/123/456' })
+ })
+
+ it('throws if prefix does not match', () => {
+ const pattern = new MatcherPatternPathStar('/teams')
+ expect(() => pattern.match('/users')).toThrow()
+ expect(() => pattern.match('/team')).toThrow()
+ })
+
+ it('is case insensitive', () => {
+ const pattern = new MatcherPatternPathStar('/Team')
+ expect(pattern.match('/team')).toEqual({ pathMatch: '' })
+ expect(pattern.match('/TEAM')).toEqual({ pathMatch: '' })
+ expect(pattern.match('/team/123')).toEqual({ pathMatch: '/123' })
+ })
+
+ it('keeps the case of the pathMatch', () => {
+ const pattern = new MatcherPatternPathStar('/team')
+ expect(pattern.match('/team/Hello')).toEqual({ pathMatch: '/Hello' })
+ expect(pattern.match('/team/Hello/World')).toEqual({
+ pathMatch: '/Hello/World',
+ })
+ expect(pattern.match('/tEaM/HElLo')).toEqual({ pathMatch: '/HElLo' })
+ })
+ })
+
+ describe('build()', () => {
+ it('builds path with pathMatch parameter', () => {
+ const pattern = new MatcherPatternPathStar('/team')
+ expect(pattern.build({ pathMatch: '/123' })).toBe('/team/123')
+ expect(pattern.build({ pathMatch: '-ok' })).toBe('/team-ok')
+ })
+
+ it('builds path with empty pathMatch', () => {
+ const pattern = new MatcherPatternPathStar('/team')
+ expect(pattern.build({ pathMatch: '' })).toBe('/team')
+ })
+
+ it('keep paths as is', () => {
+ const pattern = new MatcherPatternPathStar('/team/')
+ expect(pattern.build({ pathMatch: '/hey' })).toBe('/team//hey')
+ })
+ })
+})
import { decode, MatcherQueryParams } from './resolver'
import { EmptyParams, MatcherParamsFormatted } from './matcher-location'
import { miss } from './matchers/errors'
+import { joinPaths } from './matcher-resolve.spec'
/**
* Base interface for matcher patterns that extract params from a URL.
TParams extends MatcherParamsFormatted = MatcherParamsFormatted, // | null // | undefined // | void // so it might be a bit more convenient
> extends MatcherPattern<string, TParams> {}
+/**
+ * Allows matching a static path.
+ *
+ * @example
+ * ```ts
+ * const matcher = new MatcherPatternPathStatic('/team')
+ * matcher.match('/team') // {}
+ * matcher.match('/team/123') // throws MatchMiss
+ * matcher.build() // '/team'
+ * ```
+ */
export class MatcherPatternPathStatic
implements MatcherPatternPath<EmptyParams>
{
- constructor(private path: string) {}
+ private path: string
+ constructor(path: string) {
+ this.path = path.toLowerCase()
+ }
match(path: string): EmptyParams {
- if (path !== this.path) {
+ if (path.toLowerCase() !== this.path) {
throw miss()
}
return {}
return this.path
}
}
+
+/**
+ * Allows matching a static path folllowed by anything.
+ *
+ * @example
+ *
+ * ```ts
+ * const matcher = new MatcherPatternPathStar('/team')
+ * matcher.match('/team/123') // { pathMatch: '/123' }
+ * matcher.match('/team-123') // { pathMatch: '-123' }
+ * matcher.match('/team') // { pathMatch: '' }
+ * matcher.build({ pathMatch: '/123' }) // '/team/123'
+ * ```
+ */
+export class MatcherPatternPathStar
+ implements MatcherPatternPath<{ pathMatch: string }>
+{
+ private path: string
+ constructor(path: string = '') {
+ this.path = path.toLowerCase()
+ }
+
+ match(path: string): { pathMatch: string } {
+ const pathMatchIndex = path.toLowerCase().indexOf(this.path)
+ if (pathMatchIndex < 0) {
+ throw miss()
+ }
+ return {
+ pathMatch: path.slice(pathMatchIndex + this.path.length),
+ }
+ }
+
+ build(params: { pathMatch: string }): string {
+ return this.path + params.pathMatch
+ }
+}
+
// example of a static matcher built at runtime
// new MatcherPatternPathStatic('/')
// new MatcherPatternPathStatic('/team')
: never
}
+/**
+ * Matcher for dynamic paths, e.g. `/team/:id/:name`.
+ * Supports one, one or zero, one or more and zero or more params.
+ */
export class MatcherPatternPathDynamic<
TParams extends MatcherParamsFormatted = MatcherParamsFormatted,
> implements MatcherPatternPath<TParams>
if (__DEV__ && i !== match.length) {
console.warn(
- `Regexp matched ${match.length} params, but ${i} params are defined`
+ `Regexp matched ${match.length} params, but ${i} params are defined. Found when matching "${path}" against ${String(this.re)}`
)
}
return params
)
}
-function joinPaths(a: string | undefined, b: string) {
+export function joinPaths(a: string | undefined, b: string) {
if (a?.endsWith('/')) {
return a + b
}
MatcherParamsFormatted,
} from './matcher-location'
import {
- buildMatched,
- EXPERIMENTAL_ResolverRecord_Base,
RecordName,
MatcherQueryParams,
NEW_LocationResolved,
NEW_RouterResolver_Base,
NO_MATCH_LOCATION,
} from './resolver'
+import type {
+ MatcherPatternPath,
+ MatcherPatternQuery,
+ MatcherPatternHash,
+} from './matcher-pattern'
-export interface EXPERIMENTAL_ResolverStaticRecord
- extends EXPERIMENTAL_ResolverRecord_Base {}
+// TODO: find a better name than static that doesn't conflict with static params
+// maybe fixed or simple
+
+export interface EXPERIMENTAL_ResolverRecord_Base {
+ /**
+ * Name of the matcher. Unique across all matchers. If missing, this record
+ * cannot be matched. This is useful for grouping records.
+ */
+ name?: RecordName
+
+ /**
+ * {@link MatcherPattern} for the path section of the URI.
+ */
+ path?: MatcherPatternPath
+
+ /**
+ * {@link MatcherPattern} for the query section of the URI.
+ */
+ query?: MatcherPatternQuery
+
+ /**
+ * {@link MatcherPattern} for the hash section of the URI.
+ */
+ hash?: MatcherPatternHash
+
+ // TODO: here or in router
+ // redirect?: RouteRecordRedirectOption
+
+ parent?: EXPERIMENTAL_ResolverRecord // the parent can be matchable or not
+ // TODO: implement aliases
+ // aliasOf?: this
+}
+
+/**
+ * A group can contain other useful properties like `meta` defined by the router.
+ */
+export interface EXPERIMENTAL_ResolverRecord_Group
+ extends EXPERIMENTAL_ResolverRecord_Base {
+ name?: undefined
+ path?: undefined
+ query?: undefined
+ hash?: undefined
+}
+
+export interface EXPERIMENTAL_ResolverRecord_Matchable
+ extends EXPERIMENTAL_ResolverRecord_Base {
+ name: RecordName
+ path: MatcherPatternPath
+}
+
+export type EXPERIMENTAL_ResolverRecord =
+ | EXPERIMENTAL_ResolverRecord_Matchable
+ | EXPERIMENTAL_ResolverRecord_Group
+
+export type EXPERIMENTAL_ResolverStaticRecord = EXPERIMENTAL_ResolverRecord
export interface EXPERIMENTAL_ResolverStatic<TRecord>
extends NEW_RouterResolver_Base<TRecord> {}
+/**
+ * Build the `matched` array of a record that includes all parent records from the root to the current one.
+ */
+export function buildMatched<T extends EXPERIMENTAL_ResolverRecord>(
+ record: T
+): T[] {
+ const matched: T[] = []
+ let node: T | undefined = record
+ while (node) {
+ matched.unshift(node)
+ node = node.parent as T
+ }
+ return matched
+}
+
+/**
+ * Creates a simple resolver that must have all records defined at creation
+ * time.
+ *
+ * @template TRecord - extended type of the records
+ * @param {TRecord[]} records - Ordered array of records that will be used to resolve routes
+ * @returns a resolver that can be passed to the router
+ */
export function createStaticResolver<
- TRecord extends EXPERIMENTAL_ResolverStaticRecord,
+ TRecord extends EXPERIMENTAL_ResolverRecord_Matchable,
>(records: TRecord[]): EXPERIMENTAL_ResolverStatic<TRecord> {
// allows fast access to a matcher by name
const recordMap = new Map<RecordName, TRecord>()
recordMap.set(record.name, record)
}
- // NOTE: because of the overloads, we need to manually type the arguments
+ // NOTE: because of the overloads for `resolve`, we need to manually type the arguments
type _resolveArgs =
| [absoluteLocation: `/${string}`, currentLocation?: undefined]
| [relativeLocation: string, currentLocation: NEW_LocationResolved<TRecord>]
export function stringifyQuery(query: LocationQueryRaw | undefined): string {
let search = ''
for (let key in query) {
- // FIXME: we could do search ||= '?' so that the returned value already has the leading ?
const value = query[key]
key = encodeQueryKey(key)
if (value == null) {