import type { SearchUnion, PathnameUnion, HashUnion } from "../uri/index.ts" import { isHashIncludes, isHashLooseEqual, isHashLooseIncludes, isHashStrictEqual, isHashStrictIncludes, isSearchIncludes, isSearchLooseEqual, isSearchLooseIncludes, isSearchStrictEqual, isSearchStrictIncludes, toPathnameArray, toPathnameString, toHashObject, toHashString, toQueryObject, toQueryString, toSearch, isPathnameLooseIncludes, isPathnameStrictIncludes, isPathnameLooseEqual, isPathnameStrictEqual, } from "../uri/index.ts" const isString = (target: unknown): target is string => { return typeof target === "string" } const isObject = (target: unknown): target is object => { if (typeof target !== "object" && typeof target !== "function") { return false } if (target === null) { return false } return true } const isPlainObject = (target: unknown): target is Record => { if (isObject(target) === false) { return false } if (Object.prototype.toString.call(target) !== "[object Object]") { return false } const prototype = Object.getPrototypeOf(target) if (prototype !== null && prototype !== Object.prototype) { return false } return true } /** * @description URL 中 pathname 及其后续部分的组合值。 */ export type PartialUrl = string /** * @description 判断给定值是否可以视为 PartialUrl。 */ export const isPartialUrl = (target: unknown): target is PartialUrl => { return isString(target) } /** * @description Route 的标准化记录结构。 */ export interface RouteRecord { partialUrl: PartialUrl pathname: string search: string hash: string pathnameString: string pathnameArray: string[] query: string queryString: string queryObject: Record hashString: string hashObject: Record } const DEFAULT_ROUTE_RECORD: RouteRecord = { partialUrl: "", pathname: "", search: "", hash: "", pathnameString: "", pathnameArray: [], query: "", queryString: "", queryObject: {}, hashString: "", hashObject: {}, } /** * @description 判断给定值是否满足 RouteRecord 的最小公共结构。 */ export const isRouteRecord = (target: unknown): target is RouteRecord => { if (isPlainObject(target) === false) { return false } if ("partialUrl" in target === false) { return false } if (isPartialUrl(target["partialUrl"]) === false) { return false } if ("pathname" in target === false) { return false } if (isString(target["pathname"]) === false) { return false } if ("search" in target === false) { return false } if (isString(target["search"]) === false) { return false } if ("hash" in target === false) { return false } if (isString(target["hash"]) === false) { return false } return true } /** * @description 将 PartialUrl 解析为 URL 时使用的选项。 */ export interface PartialUrlToUrlOptions { partialUrl: PartialUrl baseUrl?: string | undefined } /** * @description 将 PartialUrl 解析为完整的 URL 对象。 */ export const partialUrlToUrl = (options: PartialUrlToUrlOptions): URL => { const { partialUrl, baseUrl } = options // JavaScript 的 URL 构造函数在处理相对路径时,必须提供一个完整的基准 URL。 const PLACEHOLDER_URL_BASE = "https://url.example.com/" const intermediateUrl = new URL(baseUrl ?? "/", PLACEHOLDER_URL_BASE) const resultUrl = new URL(partialUrl, intermediateUrl) return resultUrl } const constrainSearch = (search: string): string => { return search === "?" ? "" : search } const constrainHash = (hash: string): string => { return hash === "#" ? "" : hash } /** * @description 将 URL 拆解为标准化的 RouteRecord。 */ export const urlToRouteRecord = (url: URL): RouteRecord => { const pathname = toPathnameString(url.pathname) const search = constrainSearch(toSearch(url.search)) const hash = constrainHash(toHashString(url.hash)) const pathnameString = pathname const pathnameArray = toPathnameArray(pathname) const query = toQueryString(search) const queryString = toQueryString(search) const queryObject = toQueryObject(search) const hashString = hash const hashObject = toHashObject(hash) const partialUrl = `${pathnameString}${search}${hashString}` return { ...DEFAULT_ROUTE_RECORD, partialUrl, pathname, search, hash, pathnameString, pathnameArray, query, queryString, queryObject, hashString, hashObject, } } /** * @description 将 PartialUrl 直接转换为标准化的 RouteRecord。 */ export const partialUrlToRouteRecord = (partialUrl: PartialUrl): RouteRecord => { return urlToRouteRecord(partialUrlToUrl({ partialUrl })) } /** * @description Route 构造时需要的输入选项。 */ export interface RouteOptions { partialUrl: PartialUrl } /** * @description 判断给定值是否满足 RouteOptions 结构。 */ export const isRouteOptions = (target: unknown): target is RouteOptions => { if (isPlainObject(target) === false) { return false } if ("partialUrl" in target === false) { return false } if (isPartialUrl(target["partialUrl"]) === false) { return false } return true } /** * @description 维护 pathname、search 与 hash 一致性的路由模型。 * * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL} */ export class Route { protected options: RouteOptions protected record: RouteRecord protected payload: Payload | undefined constructor(options: RouteOptions) { this.options = options this.record = partialUrlToRouteRecord(options.partialUrl) this.payload = undefined } /** * @description 创建当前 Route 的深拷贝。 * * 克隆结果会复制当前的标准化地址记录与 payload,避免后续修改影响原对象。 */ clone(): Route { const newRoute = new Route({ partialUrl: this.record.partialUrl }) newRoute.setPayload(this.payload) return newRoute } /** * @description 返回当前挂载在 Route 上的业务负载。 */ getPayload(): Payload | undefined { return this.payload } /** * @description 设置当前 Route 对应的业务负载。 */ setPayload(payload: Payload | undefined): this { this.payload = payload return this } /** * @description 返回当前 RouteRecord 的深拷贝快照。 */ getRecord(): RouteRecord { return structuredClone(this.record) } protected correctPartialUrl(): this { const { pathname, search, hash } = this.record this.record.partialUrl = `${pathname}${search}${hash}` return this } /** * @description 清空当前 search 及其派生的 query 表达。 */ resetSearch(): this { this.record.search = DEFAULT_ROUTE_RECORD.search this.record.query = DEFAULT_ROUTE_RECORD.query this.record.queryString = DEFAULT_ROUTE_RECORD.queryString this.record.queryObject = DEFAULT_ROUTE_RECORD.queryObject this.correctPartialUrl() return this } /** * @description 清空当前 query 表达。 */ resetQuery(): this { this.resetSearch() return this } /** * @description 清空当前 hash 及其对象化表达。 */ resetHash(): this { this.record.hash = DEFAULT_ROUTE_RECORD.hash this.record.hashString = DEFAULT_ROUTE_RECORD.hashString this.record.hashObject = DEFAULT_ROUTE_RECORD.hashObject this.correctPartialUrl() return this } /** * @description 用完整 URL 替换当前 RouteRecord。 */ url(url: URL): this { this.record = urlToRouteRecord(url) return this } /** * @description 用 PartialUrl 替换当前 RouteRecord。 */ partialUrl(partialUrl: PartialUrl): this { this.record = partialUrlToRouteRecord(partialUrl) return this } /** * @description 更新 pathname,并在路径变化时重置 search 与 hash。 */ pathname(options: { pathname: PathnameUnion }): this { const { pathname } = options const oldPathnameString = this.record.pathnameString const newPathnameString = toPathnameString(pathname) this.record.pathname = newPathnameString this.record.pathnameString = newPathnameString this.record.pathnameArray = toPathnameArray(newPathnameString) // when path is changed, reset query and hash if (oldPathnameString !== newPathnameString) { this.resetSearch() this.resetQuery() this.resetHash() } this.correctPartialUrl() return this } /** * @description 更新 search,并同步刷新 query 的字符串与对象表示。 */ search(options: { search: SearchUnion }): this { const { search } = options this.record.search = constrainSearch(toSearch(search)) this.record.query = toQueryString(search) this.record.queryString = toQueryString(search) this.record.queryObject = toQueryObject(search) this.correctPartialUrl() return this } /** * @description 以合并方式写入 search,不存在的键会新增,已存在的键会覆盖。 */ upsertSearch(options: { search: SearchUnion }): this { const { search } = options const patchQueryObject = isString(search) ? toQueryObject(search) : search const newQueryObject = { ...this.record.queryObject, ...patchQueryObject } this.search({ search: toQueryString(newQueryObject) }) return this } /** * @description 更新 hash,并同步刷新 hash 的对象表示。 */ hash(options: { hash: HashUnion }): this { const { hash } = options if (hash === "") { this.resetHash() return this } const hashString = constrainHash(toHashString(hash)) this.record.hash = hashString this.record.hashString = hashString this.record.hashObject = toHashObject(hashString) this.correctPartialUrl() return this } /** * @description 比较两个 Route 或 RouteRecord 的 partialUrl 是否不同。 */ static distinctByPartialUrl(routeA: Route | RouteRecord, routeB: Route | RouteRecord): boolean { const routeRecordA = isRoute(routeA) ? routeA.getRecord() : routeA const routeRecordB = isRoute(routeB) ? routeB.getRecord() : routeB return routeRecordA.partialUrl !== routeRecordB.partialUrl } /** * @description 比较两个 Route 或 RouteRecord 的 pathname 是否不同。 */ static distinctByPathname(routeA: Route | RouteRecord, routeB: Route | RouteRecord): boolean { const routeRecordA = isRoute(routeA) ? routeA.getRecord() : routeA const routeRecordB = isRoute(routeB) ? routeB.getRecord() : routeB return routeRecordA.pathnameString !== routeRecordB.pathnameString } /** * @description 比较两个 Route 或 RouteRecord 的 search 是否不同。 */ static distinctBySearch(routeA: Route | RouteRecord, routeB: Route | RouteRecord): boolean { const routeRecordA = isRoute(routeA) ? routeA.getRecord() : routeA const routeRecordB = isRoute(routeB) ? routeB.getRecord() : routeB return routeRecordA.search !== routeRecordB.search } /** * @description 比较两个 Route 或 RouteRecord 的 hash 是否不同。 */ static distinctByHash(routeA: Route | RouteRecord, routeB: Route | RouteRecord): boolean { const routeRecordA = isRoute(routeA) ? routeA.getRecord() : routeA const routeRecordB = isRoute(routeB) ? routeB.getRecord() : routeB return routeRecordA.hashString !== routeRecordB.hashString } /** * @description 判断当前 Route 与目标 Route 或 RouteRecord 的 partialUrl 是否不同。 */ isDistinctByPartialUrl(route: Route | RouteRecord): boolean { const targetRouteRecord = isRoute(route) ? route.getRecord() : route return this.record.partialUrl !== targetRouteRecord.partialUrl } /** * @description 判断当前 Route 与目标 Route 或 RouteRecord 的 pathname 是否不同。 */ isDistinctByPathname(route: Route | RouteRecord): boolean { const targetRouteRecord = isRoute(route) ? route.getRecord() : route return this.record.pathnameString !== targetRouteRecord.pathnameString } /** * @description 判断当前 Route 与目标 Route 或 RouteRecord 的 search 是否不同。 */ isDistinctBySearch(route: Route | RouteRecord): boolean { const targetRouteRecord = isRoute(route) ? route.getRecord() : route return this.record.search !== targetRouteRecord.search } /** * @description 判断当前 Route 与目标 Route 或 RouteRecord 的 hash 是否不同。 */ isDistinctByHash(route: Route | RouteRecord): boolean { const targetRouteRecord = isRoute(route) ? route.getRecord() : route return this.record.hashString !== targetRouteRecord.hashString } /** * @description 以宽松模式判断当前 pathname 是否包含指定路径段。 */ isPathnameLooseIncludes(pieces: string | string[]): boolean { return isPathnameLooseIncludes(this.record.pathname, pieces) } /** * @description 以严格模式判断当前 pathname 是否包含指定路径片段。 */ isPathnameStrictIncludes(pieces: string | string[]): boolean { return isPathnameStrictIncludes(this.record.pathname, pieces) } /** * @description 以宽松模式判断当前 pathname 是否与目标值相等。 */ isPathnameLooseEqual(pathname: PathnameUnion): boolean { return isPathnameLooseEqual(this.record.pathname, pathname) } /** * @description 以严格模式判断当前 pathname 是否与目标值相等。 */ isPathnameStrictEqual(pathname: PathnameUnion): boolean { return isPathnameStrictEqual(this.record.pathname, pathname) } /** * @description 宽松包含关系:查询参数的键集合是目标查询参数键集合的子集。 * 例如:`?a=1&b=2` 包含 `?a=1`,但不包含 `?a=1&c=3`。 */ isSearchLooseIncludes(queryKeys: string | string[]): boolean { return isSearchLooseIncludes(this.record.search, queryKeys) } /** * @description 严格包含关系:查询参数的键集合与目标查询参数键集合完全相同。 * 例如:`?a=1&b=2` 严格包含 `?a=1&b=2`,但不严格包含 `?a=1` 或 `?a=1&c=3`。 */ isSearchStrictIncludes(queryKeys: string | string[]): boolean { return isSearchStrictIncludes(this.record.search, queryKeys) } /** * @description 包含关系:查询参数的键集合必须包含目标查询参数键集合,并且不能包含除目标查询参数键集合之外的其他键。 * 例如:`?a=1&b=2` 包含 `?a=1`,但不包含 `?a=1&c=3` 或 `?a=1&b=2&c=3`。 */ isSearchIncludes(query: Record): boolean { return isSearchIncludes(this.record.search, query) } /** * @description 此处的 Equal 语义与 Match 类似。 * * 宽松相等:查询参数的键集合是目标查询参数键集合的子集,并且对应的值相等。 * 例如:`?a=1&b=2` 宽松相等 `?a=1`,但不宽松相等 `?a=1&c=3`。 */ isSearchLooseEqual(query: SearchUnion): boolean { return isSearchLooseEqual(this.record.search, query) } /** * @description 严格相等:查询参数的键集合与目标查询参数键集合完全相同,并且对应的值相等。 * 例如:`?a=1&b=2` 严格相等 `?a=1&b=2`,但不严格相等 `?a=1` 或 `?a=1&c=3`。 */ isSearchStrictEqual(query: SearchUnion): boolean { return isSearchStrictEqual(this.record.search, query) } /** * @description 宽松包含关系:hash 参数的键集合是目标 hash 参数键集合的子集。 * 例如:`#a=1&b=2` 包含 `#a=1`,但不包含 `#a=1&c=3`。 */ isHashLooseIncludes(hashKeys: string | string[]): boolean { return isHashLooseIncludes(this.record.hash, hashKeys) } /** * @description 严格包含关系:hash 参数的键集合与目标 hash 参数键集合完全相同。 * 例如:`#a=1&b=2` 严格包含 `#a=1&b=2`,但不严格包含 `#a=1` 或 `#a=1&c=3`。 */ isHashStrictIncludes(hashKeys: string | string[]): boolean { return isHashStrictIncludes(this.record.hash, hashKeys) } /** * @description 包含关系:hash 参数的键集合必须包含目标 hash 参数键集合,并且不能包含除目标 hash 参数键集合之外的其他键。 * 例如:`#a=1&b=2` 包含 `#a=1`,但不包含 `#a=1&c=3` 或 `#a=1&b=2&c=3`。 */ isHashIncludes(hash: Record): boolean { return isHashIncludes(this.record.hash, hash) } /** * @description 此处的 Equal 语义与 Match 类似。 * * 宽松相等:hash 参数的键集合是目标 hash 参数键集合的子集,并且对应的值相等。 * 例如:`#a=1&b=2` 宽松相等 `#a=1`,但不宽松相等 `#a=1&c=3`。 */ isHashLooseEqual(hash: HashUnion): boolean { return isHashLooseEqual(this.record.hash, hash) } /** * @description 严格相等:hash 参数的键集合与目标 hash 参数键集合完全相同,并且对应的值相等。 * 例如:`#a=1&b=2` 严格相等 `#a=1&b=2`,但不严格相等 `#a=1` 或 `#a=1&c=3`。 */ isHashStrictEqual(hash: HashUnion): boolean { return isHashStrictEqual(this.record.hash, hash) } } /** * @description 判断给定值是否为 Route 实例。 */ export const isRoute = (target: unknown): target is Route => { return target instanceof Route } /** * @description 将多种 Route 输入统一转换为 Route 实例。 * * 如果输入已经是 Route 实例,则直接返回原对象;否则会基于可识别的路由表达创建新实例。 */ export const createRoute = (target: PartialUrl | RouteRecord | RouteOptions | Route): Route => { if (isPartialUrl(target)) { return new Route({ partialUrl: target }) } else if (isRouteRecord(target)) { return new Route({ partialUrl: target.partialUrl }) } else if (isRouteOptions(target)) { return new Route(target) } else if (isRoute(target)) { return target } else { throw new TypeError( '"target" is expected to be type of "String" or "Route" or "RouteRecord" or "RouteOptions".', ) } } /** * @description 创建一个指向根路径的空 Route。 */ export const emptyRoute = (): Route => { return new Route({ partialUrl: DEFAULT_ROUTE_RECORD.partialUrl }) }