diff --git a/lib/fromPath.ts b/lib/fromPath.ts index 7135661..927e4d5 100644 --- a/lib/fromPath.ts +++ b/lib/fromPath.ts @@ -1,4 +1,5 @@ import { Either } from "./either.js"; +import { normalizeRest } from "./normalize.js"; type Mode = | "initialize" @@ -9,16 +10,16 @@ type Mode = | "collectRest"; +export type ParseResult = + Either; rest: string; score: number }> + // todo-james this is already single pass / O(n) // but we could probably scan both the pattern and path // mutually which could be faster, worth a try? export function safe( _path: string, _pattern: string -): Either< - Error, - { value: Record; rest: string; score: number } -> { +): ParseResult { if (_path == null ) { throw new Error('Provided path was null but must be a URL path') @@ -43,17 +44,10 @@ export function safe( let mode: Mode = "initialize"; let prevMode: Mode = "initialize"; let score = 0; - const maxIterations = path.length + pattern.length + 1; + const maxIterations = path.length + pattern.length; let iterations = 0; // eslint-disable-next-line no-constant-condition while (true) { - if (iterations >= maxIterations) { - throw new Error( - `Unexpected recursive logic while parsing path '${_path}' using pattern '${_pattern}'` - ); - } - iterations++; - if (mode == "initialize") { while (pattern[patternI] === "/") { patternI++; @@ -129,6 +123,13 @@ export function safe( } } + if (iterations >= maxIterations) { + throw new Error( + `Unexpected recursive logic while parsing path '${_path}' using pattern '${_pattern}'` + ); + } + iterations++; + if ( (mode == "collectLiteralName" || mode == "collectVarName") && pattern[patternI] === "/" @@ -200,7 +201,7 @@ export function safe( if (rest) { score = Math.max(0, score - 1); } - return { type: "Either", tag: "Right", value: { rest, value, score } }; + return { type: "Either", tag: "Right", value: { rest: normalizeRest(rest), value, score } }; } export function unsafe( _path: string, _pattern: string): { rest: string, value: Record, score: number } { diff --git a/lib/index.ts b/lib/index.ts index 14a4b35..db29d3f 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -2,37 +2,101 @@ import { Either } from "./either.js"; import * as ParsePath from "./fromPath.js"; +import { normalizePathSegment, normalizeRest } from "./normalize.js"; export type Patterns = string | string[]; export type DefinitionResponse = [any, Patterns] | Patterns; -export type Definition = Record DefinitionResponse>; -export type Constructors = { +export type DefinitionValue = + | ((v: any) => DefinitionResponse) + | DefinitionResponse; + +export type Definition = Record; +export type ValueFromDefinitionValue = V extends DefinitionValue + ? V extends (v: any) => DefinitionResponse + ? PV extends null + ? Parameters[0] + : Parameters[0] & PV + : PV extends null + ? Record + : PV + : never; + +export type Constructors = { [R in keyof D]: { - (value: (Parameters[0] & { rest?: string } )): { + (value: ValueFromDefinitionValue, config?: { rest?: string }): { type: N; tag: R; - value: Parameters[0]; + context: RouteContext; + value: ValueFromDefinitionValue; }; + } & { + create( + childDefinition: D2 + ): Superoute< + R extends string ? `${N}.${R}` : string, + D2, + ValueFromDefinitionValue + >; + ['superouter/metadata']: { value: ValueFromDefinitionValue, tag: R } }; }; -export type MatchOptions = { + +export type MatchOptions = { [R in keyof D]: { - (value: Parameters[0]): T; + (value: ValueFromDefinitionValue): T; }; }; -export type API = { - type: N, + +// The route context is where we store metadata we need to recreate a URL that +// is not part of the typed value set. +// +// Currently we only use the rest excess unparsed url fragments, but we may +// add more metadata in the future. +export type RouteContext = { + rest: string; +}; + +export type API = { + type: N; definition: { [R in keyof D]: D[R] }; patterns: { [R in keyof D]: string[] }; - toPath(instance: InternalInstance): string; - toPathSafe(instance: InternalInstance): Either; - fromPath(url: string): InternalInstance; - fromPathSafe(url: string): Either>; - match: ( - instance: InternalInstance, - options: MatchOptions - ) => T; + toPath(instance: InternalInstance): string; + toPathSafe(instance: InternalInstance): Either; + toLocalPath(instance: InternalInstance): string; + toPathSafe( + instance: InternalInstance + ): Either; + fromPath(path: string): InternalInstance; + fromPathSafe(path: string): Either>; + fromLocalPath( + path: string, + defaults: PV + ): InternalInstance; + fromLocalPathSafe( + path: string, + defaults: PV + ): Either>; + fromLocalPathOr( + fallback: () => InternalInstance, + path: string, + defaults: PV + ): InternalInstance; + fromPathOr( + fallback: () => InternalInstance, + path: string, + ): InternalInstance; + match( + instance: InternalInstance, + options: MatchOptions + ): T; + match( + options: MatchOptions + ): (instance: InternalInstance) => T; + def: ( + fn: (x: InternalInstance) => T + ) => (x: InternalInstance) => T; + parentValue: PV; }; export type Is = `is${R}`; @@ -40,105 +104,178 @@ export type Is = `is${R}`; export type Get = `get${R}`; type Otherwise = { - otherwise: >(tags: R[]) => (fn: () => T) => { - [k in R]: () => T - } -} + otherwise: >( + tags: R[] + ) => (fn: () => T) => { + [k in R]: () => T; + }; +}; + +type OtherwiseEmpty = { + otherwise: () => (fn: () => T) => { + [k in keyof D]: () => T; + }; +}; type InternalInstance< N extends string, D extends Definition, - K extends keyof D -> = ReturnType[K]>; + K extends keyof D, + PV +> = ReturnType[K]>; -export type Instance > = InternalInstance +export type Instance> = InternalInstance< + A["type"], + A["definition"], + keyof A["definition"], + A["parentValue"] +>; -export type Superoute = Constructors< - N, - D -> & - API & - RouteIs & - RouteGet & - Otherwise; +export type Superoute< + N extends string, + D extends Definition, + PV +> = Constructors & + API & + RouteIs & + RouteGet & + Otherwise & + OtherwiseEmpty; -export type RouteIs = { +export type RouteIs = { [Key in keyof D as Is]: ( - route: InternalInstance + route: InternalInstance ) => boolean; }; -export type RouteGet = { +export type RouteGet = { [Key in keyof D as Get]: ( fallback: T, - visitor: (value: InternalValue) => T, - route: InternalInstance - ) => boolean; + visitor: (value: ValueFromDefinitionValue) => T, + route: InternalInstance + ) => T; }; -function match(instance: any, options: any): any { - return options[instance.tag](instance.value); +function match(...args: any[]): any { + if (args.length == 2) { + const [instance, options] = args + return options[instance.tag](instance.value); + } else if (args.length == 1) { + const [ options ] = args + return (instance: any) => { + return options[instance.tag](instance.value); + } + } else { + throw new Error('Expected either 2 or 1 parameters but received: ' + args.length) + } } -function otherwise(tags:string[]) { - return (fn: any) => - Object.fromEntries( - tags.map( tag => [tag, fn] ) - ) +function def(fn: any): any { + return (x: any) => fn(x); } +function otherwise(tags: string[]) { + return (fn: any) => Object.fromEntries(tags.map((tag) => [tag, fn])); +} -export function type( - type: N, - routes: D -): Superoute { - function toPathSafe(route: any): Either { - const errors = []; - const paths = []; - const ranks: Record = {}; - let bestPath: string[] | null = null; - let bestRank = 0; - let error: string | null = null; - for (const pattern of api.patterns[route.tag]) { - let path: string[] | null = []; - let rank = 0; - for (const segment of pattern.split("/")) { - if (segment.startsWith(":")) { - const name = segment.slice(1); - if (route.value[name]) { - path.push(route.value[name]); - rank += 2; - } else { - error = `Could not build pattern ${pattern} from value ${JSON.stringify( - route.value - )} as '${name} was empty'`; - errors.push(error); - path = null - break; - } +function toPathInternalSafe( + route: any, + parentPatterns: string[], + localPatterns: Record +): Either { + const errors = []; + const paths = []; + const ranks: Record = {}; + let bestPath: string[] | null = null; + let bestRank = 0; + let error: string | null = null; + + const pathPrefix = []; + + // generate path prefix first + for (const parentPattern of parentPatterns) { + for (const segment of parentPattern.split("/")) { + if (segment.startsWith(":")) { + const name = segment.slice(1); + if (route.value[name]) { + // intentional + pathPrefix.push(route.value[name]); } else { - path.push(segment) - rank += 4; + error = `Could not build pattern ${parentPattern} from value ${JSON.stringify( + route.value + )} as '${name} was empty'`; + errors.push(error); + break; } + } else { + pathPrefix.push(segment); } - if (path != null) { - paths.push(path); - ranks[pattern] = rank; - if (rank > bestRank || bestPath == null) { - bestRank = rank; - bestPath = path; + } + } + + for (const _pattern of localPatterns[route.tag]) { + const pattern = normalizePathSegment(_pattern); + let path: string[] | null = []; + let rank = 0; + for (const segment of pattern.split("/")) { + if (segment.startsWith(":")) { + const name = segment.slice(1); + if (route.value[name]) { + path.push(route.value[name]); + rank += 2; + } else { + error = `Could not build pattern ${pattern} from value ${JSON.stringify( + route.value + )} as '${name} was empty'`; + errors.push(error); + path = null; + break; } - continue; + } else { + path.push(segment); + rank += 4; } } - if (bestPath) { - return { type: "Either", tag: "Right", value: bestPath.join("/") }; - } else if (errors.length) { - return { type: "Either", tag: "Left", value: new Error(errors[0]) }; - } else { - throw new Error(`Unexpected failure converting route ${route} to url`); + if (path != null) { + paths.push(path); + ranks[pattern] = rank; + if (rank > bestRank || bestPath == null) { + bestRank = rank; + bestPath = path; + } + continue; + } + } + + if (bestPath) { + const value = + (pathPrefix.length ? normalizePathSegment(pathPrefix.join("/")) : "") + + normalizePathSegment(bestPath.concat(route.context.rest).join("/")); + return { type: "Either", tag: "Right", value }; + } else if (errors.length) { + return { type: "Either", tag: "Left", value: new Error(errors[0]) }; + } else { + throw new Error(`Unexpected failure converting route ${route} to url`); + } +} + +function internalCreate< + N extends string, + D extends Definition, + C extends RouteContext +>( + type: N, + routes: D, + routeContext: C, + parentPatterns: string[] +): Superoute { + function toPathSafe(route: any): Either { + const answer = toPathInternalSafe(route, parentPatterns, api.patterns); + if (answer.tag === "Right") { + return Either.Right(normalizePathSegment(answer.value)); } + return answer; } function toPath(route: any) { @@ -150,20 +287,118 @@ export function type( } } - function fromPathSafe(url: string): Either { + function toLocalPath(route: any) { + const result = toPathInternalSafe(route, [], api.patterns); + if (result.tag === "Left") { + throw result.value; + } else { + return result.value; + } + } + + function fromLocalPathOr( + otherwise: () => any, + path: string, + defaults: any + ): any { + const res = fromLocalPathSafe(path, defaults); + if (res.tag == "Left") { + return otherwise(); + } else { + return res.value; + } + } + + function fromPathOr(otherwise: () => any, path: string): any { + const res = fromLocalPathSafe(path, {}); + if (res.tag == "Left") { + return otherwise(); + } else { + return res.value; + } + } + + function fromPathSafe(path: string): Either { + return fromPathSafeInternal(path, parentPatterns, api.patterns, {}); + } + + function fromPath(path: string): Either { + const res = fromPathSafe(path); + if (res.tag === "Left") { + throw res.value; + } else { + return res.value; + } + } + + function fromLocalPathSafe(path: string, defaults: any): Either { + return fromPathSafeInternal(path, [], api.patterns, defaults); + } + + function fromPathSafeInternal( + path: string, + parentPatterns: string[], + localPatterns: Record, + defaults: any + ): Either { + if (path.includes("://")) { + return Either.Left( + new Error( + `Please provide a path segment instead of a complete url found:'${path}'` + ) + ); + } + let bestRank = 0; + let bestParentMatch = { + value: {}, + rest: "", + score: -1, + }; + let lastParsedParentPath: ParsePath.ParseResult; + + for (const parentPattern of parentPatterns) { + const parsedPrefix = ParsePath.safe(path, parentPattern); + lastParsedParentPath = parsedPrefix; + + if (parsedPrefix.tag === "Left") { + continue; + } + + if (parsedPrefix.value.score > bestRank) { + bestParentMatch = parsedPrefix.value; + } + } + + if (parentPatterns.length && bestParentMatch.score === -1) { + return lastParsedParentPath!; + } + + const { value: parentValue = {}, rest } = bestParentMatch; + + if (rest !== "") { + path = rest; + bestRank = 0; + } + let bestRoute: any = null; let error: any = null; - for (const [tag, patterns] of Object.entries(api.patterns as Record) ) { + for (const [tag, patterns] of Object.entries(localPatterns)) { for (const pattern of patterns) { - const result = ParsePath.safe(url, pattern); + const result = ParsePath.safe(path, normalizePathSegment(pattern)); if (result.tag === "Left") { if (error == null) { error = result; } } else { if (bestRoute == null || result.value.score > bestRank) { - bestRoute = { type, tag, value: { ...result.value.value, rest: result.value.rest } }; + bestRoute = api[tag]( + { + ...result.value.value, + }, + { rest: result.value.rest } + ); + bestRank = result.value.score; } } @@ -178,11 +413,17 @@ export function type( type: "Either", tag: "Left", value: new Error( - `Could not parse url ${url} into any pattern on type ${type}` + `Could not parse url ${path} into any pattern on type ${type}` ), }; } } else { + bestRoute.value = Object.assign( + {}, + defaults, + bestRoute.value, + parentPatterns.length ? parentValue : {} + ); return { type: "Either", tag: "Right", @@ -191,12 +432,12 @@ export function type( } } - function fromPath(route: any): any { - const res = fromPathSafe(route); + function fromLocalPath(route: any, defaults: any): any { + const res = fromLocalPathSafe(route, defaults); if (res.tag == "Left") { throw res.value; } else { - return res.value + return res.value; } } @@ -208,16 +449,43 @@ export function type( toPathSafe, fromPath, fromPathSafe, + toLocalPath, + toLocalPathSafe: toPathInternalSafe, + fromLocalPath, + fromLocalPathSafe, + fromPathOr: fromPathOr, + fromLocalPathOr: fromLocalPathOr, match, - otherwise + def, + otherwise: (...args: any[]) => { + if (args.length === 0) { + return otherwise(Object.keys(routes)); + } + return otherwise(args[0]); + }, }; - for (const [tag, of] of Object.entries(routes)) { - api[tag] = (value:any = {}) => { - if ( !value.rest ) { - value.rest = '/' - } - return { type, tag, value } + for (const [tag, dv] of Object.entries(routes)) { + const of = typeof dv == "function" ? dv : () => dv; + api[tag] = (_value: any = {}, config?: { rest?: string }) => { + const { ...value } = _value; + const context: RouteContext = { + // prefix: normalizePathSegment(routeContext.prefix), + rest: normalizeRest(config?.rest ?? ""), + }; + + return { type, tag, value: { ...value }, context }; + }; + + api[tag].create = (definition: Definition) => { + return internalCreate( + `${type}.${tag}`, + definition, + routeContext, + (parentPatterns.length ? parentPatterns : [""]).flatMap((x) => + api.patterns[tag].map((childPattern: string) => x + childPattern) + ) + ); }; const res = of({}); @@ -241,21 +509,40 @@ export function type( api[`get${tag}`] = (fallback: any, f: any, v: any) => v.tag === tag ? f(v.value) : fallback; } - return api as any as Superoute; + return api as any as Superoute; +} + +export function create( + definition: D +): Superoute; +export function create( + name: N, + definition: D +): Superoute; +export function create( + ...args: any[] +): Superoute { + if (typeof args[0] === "string") { + return internalCreate(args[0] as N, args[1] as D, { rest: "" }, []); + } else { + return internalCreate("Main" as N, args[0] as D, { rest: "" }, []); + } } -type InternalValue any> = Parameters[0]; - -export type Value = - A extends API - ? Instance["value"] - : A extends Instance - ? A["value"] - : never - -export type Tag = - A extends API - ? Instance["tag"] - : A extends Instance - ? A["tag"] - : never +export type Tag = A extends API + ? Instance["tag"] + : A extends Instance + ? A["tag"] + : A extends { ['superouter/metadata']: any } + ? A["superouter/metadata"]["value"] + : never; + +export type Value = A extends API + ? Instance["value"] + : A extends Instance + ? A["value"] + : A extends { ['superouter/metadata']: any } + ? A["superouter/metadata"]["value"] + : never; + +export { normalizePathSegment, normalizeRest }; diff --git a/lib/normalize.ts b/lib/normalize.ts new file mode 100644 index 0000000..826beb2 --- /dev/null +++ b/lib/normalize.ts @@ -0,0 +1,25 @@ + +export function normalizePathSegment(s: string): string{ + if( s == '' || s == '/' ) { + return '/' + } else if ( s.startsWith('/') && s.endsWith('/') ) { + return s.slice(0, -1) + } else if ( !s.startsWith('/') && s.endsWith('/') ) { + return '/' + s.slice(0, -1) + } else { + return s + } +} + +export function normalizeRest(s:string): string { + if ( !s ) { + s = '' + } + if (s.at(0) == '/') { + s = s.slice(1) + } + if (s.at(-1) == '/') { + s = s.slice(0,-1) + } + return s +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b4dc116..7cb5d4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,22 @@ { "name": "superouter", - "version": "1.0.1-next.5", + "version": "1.0.1-next.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "superouter", - "version": "1.0.1-next.5", + "version": "1.0.1-next.19", "license": "MIT", "dependencies": { "@types/node": "^20.11.17" }, "devDependencies": { - "@rollup/plugin-node-resolve": "^15.0.2", - "@rollup/plugin-swc": "^0.1.0", "@types/mithril": "^2.0.12", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "esbuild": "^0.20.0", "eslint": "^8.56.0", - "rollup": "^3.21.1", "tsx": "^4.7.0", "typescript": "^5.0.4" } @@ -525,303 +522,6 @@ "node": ">= 8" } }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", - "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-builtin-module": "^3.2.1", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-swc": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-swc/-/plugin-swc-0.1.1.tgz", - "integrity": "sha512-nDbcgC39Y0NWsvSd3O2B7qLB19lVkX5rCqRVDqkS991WL2WVpJyp2vX0pa2W5OviwyG/UFxCypkc6NiC6C6r5Q==", - "dev": true, - "dependencies": { - "smob": "^1.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "@swc/core": "^1.3.x", - "rollup": "^3.x" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", - "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@swc/core": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.4.0.tgz", - "integrity": "sha512-wc5DMI5BJftnK0Fyx9SNJKkA0+BZSJQx8430yutWmsILkHMBD3Yd9GhlMaxasab9RhgKqZp7Ht30hUYO5ZDvQg==", - "dev": true, - "hasInstallScript": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.4.0", - "@swc/core-darwin-x64": "1.4.0", - "@swc/core-linux-arm-gnueabihf": "1.4.0", - "@swc/core-linux-arm64-gnu": "1.4.0", - "@swc/core-linux-arm64-musl": "1.4.0", - "@swc/core-linux-x64-gnu": "1.4.0", - "@swc/core-linux-x64-musl": "1.4.0", - "@swc/core-win32-arm64-msvc": "1.4.0", - "@swc/core-win32-ia32-msvc": "1.4.0", - "@swc/core-win32-x64-msvc": "1.4.0" - }, - "peerDependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.4.0.tgz", - "integrity": "sha512-UTJ/Vz+s7Pagef6HmufWt6Rs0aUu+EJF4Pzuwvr7JQQ5b1DZeAAUeUtkUTFx/PvCbM8Xfw4XdKBUZfrIKCfW8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.4.0.tgz", - "integrity": "sha512-f8v58u2GsGak8EtZFN9guXqE0Ep10Suny6xriaW2d8FGqESPyNrnBzli3aqkSeQk5gGqu2zJ7WiiKp3XoUOidA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.4.0.tgz", - "integrity": "sha512-q2KAkBzmPcTnRij/Y1fgHCKAGevUX/H4uUESrw1J5gmUg9Qip6onKV80lTumA1/aooGJ18LOsB31qdbwmZk9OA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.4.0.tgz", - "integrity": "sha512-SknGu96W0mzHtLHWm+62fk5+Omp9fMPFO7AWyGFmz2tr8EgRRXtTSrBUnWhAbgcalnhen48GsvtMdxf1KNputg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.4.0.tgz", - "integrity": "sha512-/k3TDvpBRMDNskHooNN1KqwUhcwkfBlIYxRTnJvsfT2C7My4pffR+4KXmt0IKynlTTbCdlU/4jgX4801FSuliw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.4.0.tgz", - "integrity": "sha512-GYsTMvNt5+WTVlwwQzOOWsPMw6P/F41u5PGHWmfev8Nd4QJ1h3rWPySKk4mV42IJwH9MgQCVSl3ygwNqwl6kFg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.4.0.tgz", - "integrity": "sha512-jGVPdM/VwF7kK/uYRW5N6FwzKf/FnDjGIR3RPvQokjYJy7Auk+3Oj21C0Jev7sIT9RYnO/TrFEoEozKeD/z2Qw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.4.0.tgz", - "integrity": "sha512-biHYm1AronEKlt47O/H8sSOBM2BKXMmWT+ApvlxUw50m1RGNnVnE0bgY7tylFuuSiWyXsQPJbmUV708JqORXVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.4.0.tgz", - "integrity": "sha512-TL5L2tFQb19kJwv6+elToGBj74QXCn9j+hZfwQatvZEJRA5rDK16eH6oAE751dGUArhnWlW3Vj65hViPvTuycw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.4.0.tgz", - "integrity": "sha512-e2xVezU7XZ2Stzn4i7TOQe2Kn84oYdG0M3A7XI7oTdcpsKCcKwgiMoroiAhqCv+iN20KNqhnWwJiUiTj/qN5AA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "peer": true - }, - "node_modules/@swc/types": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", - "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", - "dev": true, - "peer": true - }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -842,12 +542,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true - }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -1163,18 +857,6 @@ "node": ">=8" } }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1227,15 +909,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1513,12 +1186,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, "node_modules/esutils": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", @@ -1663,15 +1330,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-tsconfig": { "version": "4.7.2", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", @@ -1754,18 +1412,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -1816,33 +1462,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "node_modules/is-builtin-module": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", - "dev": true, - "dependencies": { - "builtin-modules": "^3.3.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1864,12 +1483,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2120,12 +1733,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -2185,23 +1792,6 @@ } ] }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2245,22 +1835,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -2329,12 +1903,6 @@ "node": ">=8" } }, - "node_modules/smob": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.4.1.tgz", - "integrity": "sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==", - "dev": true - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2359,18 +1927,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index a4669ec..cf8c64b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superouter", - "version": "1.0.1-next.5", + "version": "1.0.1-next.19", "description": "", "type": "module", "main": "./dist/superouter.esm.js", @@ -10,7 +10,7 @@ "scripts": { "test": "node --import tsx --test test/*.ts", "dev": "node --watch --import tsx --test test/*.ts", - "build:bundle": "esbuild lib/index.ts --bundle --format=esm --sourcemap --allow-overwrite --outfile=./dist/superouter.js", + "build:bundle": "esbuild lib/index.ts --bundle --format=esm --sourcemap --allow-overwrite --outfile=./dist/superouter.esm.js", "build:types": "npx tsc", "build:clear": "rm -fr ./dist", "build": "npm run build:clear && npm run build:bundle && npm run build:types", diff --git a/readme.md b/readme.md index 605f364..19bffac 100644 --- a/readme.md +++ b/readme.md @@ -3,21 +3,34 @@ ```typescript import * as superouter from "superouter"; -const route = superouter.type("Example", { - Home: (_: { organization_id: string }) => [_, `/:organization_id`], - Group: (_: { organization_id: string; group_id: string }) => [ - _, - `/:organization_id/groups/:group_id`, - ], +const route = superouter.create({ + + Home: (_: { + organization_id: string + }) => `/:organization_id`, + + Group: (_: { + organization_id: string; + group_id: string + }) => `/:organization_id/groups/:group_id`, + }); -route.Home({ organization: "hi" }); // Type error: No property named organization +// Typescript: No property named organization +route.Home({ organization: "hi" }); -route.Home({ organization_id: 4 }); // Type error: Expected string instead of number +// Typescript: Expected string instead of number +route.Home({ organization_id: 4 }); -route.Group({ organization_id: "1" }); // Type error: Expected property group_id:string +// Typescript: Expected property group_id:string +route.Group({ organization_id: "1" }); -route.toPath(route.Group({ organization_id: "1", group_id: "2" })); +route.toPath( + route.Group({ + organization_id: "1", + group_id: "2" + }) +); //=> /1/groups/2 route.fromPath("/1/groups/2"); @@ -40,178 +53,411 @@ npm install superouter@next A router that encourages you to use names and types instead of dealing with URL strings in your application logic. -- Re-designed to take advantage of modern Typescript features -- Simple: < N (TBD) LOC -- Fast: Simple pattern matching rules and a custom (and imperative) parser to ensure the router is faster -- Ranked match: Matches the most specific route +- Modern: Re-designed to take advantage of modern Typescript features +- Small: < 500 LOC (8kb unminifed, 3.7kb minified) +- Simple: No state, no history API, just data +- Fast: Simple pattern matching rules with a single pass parser +- Specific: Matches the most specific route, not just an order dependent regex ## Why -Route state is the primary state in your application. If we derive state from the URL we automatically get deep linkable/sharable apps. We can cold boot our apps from the URL state and not have to click multiple times to get back to what we were doing during development. Relying on a URL state as the foundation of your app state leads to a better experience for users and developers and it forces us to think about what is the total possibility space for a particular screen ahead of time. +### UX over DX + +Advancements in hot module reloading has potentially misguided us into focusing too much on DX and not UX. If we refresh the app constantly we are forced to experience load times, and route navigations repeatedly - just like a user. If we fix the actual problem (resumable state) by embedding more state in the URL instead of hiding it behind fancy tools both user and develop benefits. + +### Route state deserves to be structured ### + +Route state is the primary state in your application. If we derive state from the URL we automatically get deep linkable/sharable apps. We can cold boot our apps from the URL state and not have to click multiple times to get back to what we were doing during development. Relying on URL state as the foundation of your app state leads to a better experience for users and developers and it forces us to think about what is the total possibility space for a particular screen ahead of time. + + +If we are going to rely on route state so much, then we should probably not do stringly typed checks against URL pathnames. We should instead match on data. -Advancements in hot module reloading has potentially misguided us into focusing too much on DX and not UX. If we refresh the app constantly we are forced to experience load times, and route navigations repeatedly - just like a user. So let's fix the actual problem by embedding more state in the URL instead of hiding it behind fancy tools. +### Serializable by Design -If we are going to rely on route state so much, then we should probably not do stringly checks against URL pathnames. We should instead match on data. +_superouter_ instances are just data, they have no instance methods. This is useful for recovering route state. You can store complete rich data routes in localStorage, your state management library or your database. -*superouter* gives you a data-centric experience for dealing with route state. *uperouter* instances are just data, they have no instance methods, you can store them in localStorage, in your state management library, in your database etc - this is by design. -*superouter* is deliberately small and simple. You are encouraged to build specific niceties on top of *superouter* for your framework of choice. +### Tagged Unions -*superouter* also encourages you to think of your route state as a tagged union type. And so the API offers affordances to match on route state and handle each case specifically with the data that is expected for that given state. This is more reliable than adhoc ternaries / if statements that match on specific URL paths and aren't updated as your route definitions organically evolve. +Superouter treats route states as separate possible states within a tagged union. Each state gets a name, and your app logic can switch behaviour / rendering based on that tag instead of looking at URL strings. + +The only place in your codebase that should need to deal with URLs is in the definition of your superouter type. ## API ### Creating a route type: -First we define the supertype. We do so via the `superouter.type` function. The first argument is the name of your route. The name is there so you can have different route types for different parts of your app and each route type is incompatible with the others methods. - -The second argument is a record where the key is the name of the route and the value is a function: `(value:T) => [T, string]`. +First we define the route type. We do so via the `superouter.create` function. The first argument is the name of your route, but if you skip it, we name the route `Main`. -*superouter* analyzes your definition via `Parameters` to infer the structure of each route subtype. +The second argument is a record where the key is the name of the route and the value is a function: `(value:T) => string` or just a `string` if theres no data to be passed from the url template. -The function takes the arguments you expect to parse from your URL pattern and returns the pattern that will be used to parse the URL string. +The function should specify the shape of the data that can be parsed from the url fragment. ```js -const route = superouter.type("Example", { - Home: (_: { organization_id: string }) => `/:organization_id`, - Group: (_: { organization_id: string, group_id: string }) => - `/:organization_id/groups/:group_id`, -}); +const route = + superouter.create({ + + Home: (_: { organization_id: string }) => + `/:organization_id`, + + Group: (_: { + organization_id: string, + group_id: string + }) => `/:organization_id/groups/:group_id`, + + }); ``` -In the above example, typescript now knows `route.Group` can only be constructed with both an `organization_id` and a `group_id` whereas `Route.Home` only needs an `organization_id`. We also generate helper methods, and typescript knows this dynamic methods exist e.g. `isGroup` or `isHome`. +We use this type and pattern information to build the constructors for each route member type, and various utils. -> 🤓 We call `Example` our supertype, and `Example.Group` and `Example.Home` our subtypes. +E.g. in the above example, typescript now knows `route.Group` can only be constructed with both an `organization_id` and a `group_id` whereas `Route.Home` only needs an `organization_id`. + +> 🤓 We call `Example` our route type, and `Example.Group` and `Example.Home` our member types. ### `is[Tag]` ```typescript -Example.isA( Example.A({ a_id: 'cool' })) +Example.isA(Example.A({ a_id: "cool" })); // => true -Example.isA( Example.C({})) +Example.isA(Example.C({})); // => false ``` -For every subtype of your route there is a generated route to extract if a specific instance has that tag. +For every member type of your route there is a generated route to extract if a specific instance has that tag. > You can also just check the `.tag` property on the route instance ### `get[Tag]` ```typescript -Example.getA( 0, x => Number(x.a_id), Example.A({ a_id: '4' })) +Example.getA( + 0 + , (x) => Number(x.a_id) + , Example.A({ a_id: "4" }) +); // => 4 -Example.getA( 0, x => Number(x.a_id), Example.B({ b_id: '2' })) +Example.getA( + 0 + , (x) => Number(x.a_id) + , Example.B({ b_id: "2" }) +); // => 0 ``` -For every subtype of your route there is a generated route to extract a value from a specific route. You also pass in a default value to ensure you are handling every case. +For every member type of your route there is a generated route to extract a value from a specific route. You also pass in a default value to ensure you are handling every case. > You can also access the `.value` property on the route instance but you'd have to type narrow on tag anyway, this is likely more convenient. ### `match` ```typescript -const Example = superouter.type("Example", { +const Example = superouter.create({ A: (_: { a_id: string }) => `/a/:a_id`, B: (_: { b_id: string }) => `/b/:b_id`, - C: (_: { c_id?: string }) => [`/c`, '/c/:c_id'], + C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], }); -type Instance = superouter.Instance +const f = + Example.match({ + A: ({ a_id }) => Number(a_id), + B: ({ b_id }) => Number(b_id), + C: ({ c_id }) => (c_id ? Number(c_id) : 0), + }); -const f = (route: Instance) => Example.match( route, { - A: ({ a_id }) => Number(a_id), - B: ({ b_id }) => Number(b_id), - C: ({ c_id }) => c_id ? Number(c_id) : 0 -}) - -f(Example.A({ a_id: '4' })) +f(Example.A({ a_id: "4" })); //=> 4 -f(Example.B({ b_id: '2' })) +f(Example.B({ b_id: "2" })); //=> 2 -f(Example.C({ c_id: '100' })) +f(Example.C({ c_id: "100" })); //=> 100 -f(Example.C({})) +f(Example.C({})); //=> 0 ``` -Convert a route type into another type by matching on every value. In the avove example we're converting all routes to a `number`. - -`.match` is a discriminated union, which means you are forced to handle every case, if a case is missing, it won't typecheck. +Convert a route type into another type by matching on every value. In the above example we're converting all routes to a `number`. -### `otherwise` | `_` +### `otherwise` ```typescript // This example extends the above `match` example. // Create a function that handles cases B and C -const _ = Example.otherwise(['B', 'C']) +const _ = Example.otherwise(["B", "C"]); + +// B and C are handled by _(...) +// so we only need to specify A +// typescript will complain if you +// haven't handled all the cases +const g = + Example.match({ + // B and C will be -1 + ..._(() => -1), + A: () => 1, + }); + + +g(Example.A({ a_id: "cool" })); +//=> 1 -// -const g = (r: Instance) => Example.match(r, { - A: () => 1, +g(Example.B({ b_id: "cool" })); +//=> -1 - // B and C will be -1 - ..._(() => -1) -}) +g(Example.C({ c_id: "cool" })); +//=> -1 +``` +`.otherwise` is a helper to be used in combination with `.match`. It allows you to select a subset of routes and handle them uniformly. You can then mix in this default set into a match. -g(Example.A({ a_id: 'cool' })) -//=> 1 +In the context of routing this is useful when there are sets of similar routes within a larger superset, e.g. routes related to auth/access, or routes that may not have some meta context like an `organization_id`. +### `type.toPath` -g(Example.B({ b_id: 'cool' })) -//=> -1 +```typescript +Example.toPath(Example.A({ a_id: "cool" })); +//=> /a/cool + +Example.toPath(Example.A({ a_id: "" })); +//=> +// throw new Error( +// `Expected binding for path literal ':a_id' +// but instead found nothing` +// ) +``` +Attempts to transform an instance of your route route type into a path segment according to patterns specified in the definition. -g(Example.C({ c_id: 'cool' })) -//=> -1 +If it cannot satisfy the patterns you specified with the values available on the object it will throw. + +This may happen if your types are out of sync with your patterns. + +> Note any excess path segments on `instance.context.rest` will be appended to the resulting path and normalized + +### `type.fromPath` + +```typescript +Example.fromPath("/a/cool"); +//=> Example.A({ a_id: 'cool' }) +Example.fromPath( + "/incorrect/non/matching/path" +); +//=> +// throw new Error( +// `Expected binding for path literal '/a' +// but instead found '/incorrect'` +// ) ``` -`.otherwise` is a helper to be used in combination with `.match`. It allows you to select a subset of routes and handle them uniformly. You can then mix in this default set into a match. +> Note any excess path segments will appear on `.instance.context.rest` -In the context of routing this is useful when there are sets of similar routes within a larger superset, e.g. routes related to auth/access, or routes that may not have some meta context like an `organization_id`. -### `type.toPath` +### `type.toPathSafe` -### `type.fromPath` +```typescript +Example.toPathSafe(Example.A({ a_id: "cool" })); +//=> /a/cool -### `type.toPath` +Example.toPathsafe({ + type: "A", tag: "A", value: { a_id: "" } +}); +//=> { type: 'Either' +// , tag: 'Left' +// , value: +// new Error( +// 'Expected binding for path variable ':a_id' +// but instead found nothing' +// ) +// } +``` + +Largely an internal method but provided for those who'd like to avoid exceptions wherever possible. + +Attempts to transform an instance of your route route type into a path segment according to patterns specified in the definition. + +If it can satisfy the patterns you specified it will return an `Either.Right` of your path (e.g. `Either.Right('/a/cool')`) + +If it cannot satisfy the patterns you specified with the values available on the object it will return `Either.Left(new Error(...))`. + +This may happen if your types are out of sync with your patterns. + +If `Either` is an unfamiliar data structure, I recommend having a read of [The Perfect API](https://james-forbes.com/posts/the-perfect-api) + +> To extract the value from the either instance, simply check the `tag` and then conditionally access `.value` to get either the path or the error. + +> Note any excess path segments on `instance.context.rest` will be appended to the resulting path and normalized ### `type.fromPathSafe` +```typescript +Example.fromPathSafe("/a/cool"); +//=> { type: 'Either', tag: 'Right', value: Example.A({ a_id: 'cool' }) } + +Example.fromPathSafe("/incorrect/non/matching/path"); +//=> { type: 'Either' +// , tag: 'Left' +// , value: +// new Error( +// `Expected binding for path literal '/a' but instead found '/incorrect'` +// ) +// } +``` + +Largely an internal method but provided for those who'd like to avoid exceptions wherever possible. + +Attempts to transform a path segment into a member type of your route using the pattern definition supplied when the type was created. + +If it can satisfy the patterns you specified it will return an `Either.Right` of your route instance (e.g. `Either.Right(Either.A({ a_id: 'cool' }))`) + +If it cannot satisfy the patterns you specified with the values available on the object it will return `Either.Left(new Error(...))`. + +This may happen if your types are out of sync with your patterns. + +If `Either` is an unfamiliar data structure, I recommend having a read of [The Perfect API](https://james-forbes.com/posts/the-perfect-api) + +> To extract the value from the either instance, simply check the `tag` and then conditionally access `.value` to get either the path or the error. + +> Note any excess path segments will appear on `.instance.context.rest` + ### `type.patterns` +An index of all the URL patterns provided at definition time. + +```typescript +const Example = superouter.create({ + A: (_: { a_id: string }) => `/a/:a_id`, + B: (_: { b_id: string }) => `/b/:b_id`, + C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], +}); + +Example.patterns; +// => { +// A: ['/a/:a_id'], +// B: ['/b/:b_id'], +// C: ['/c', '/c/:c_id'] +// } +``` + +> Note the structure is normalized so that all values are an array of patterns even if only one string pattern was provided. + ### `type.definition` -Returns the definition object you passed in when initialized the type. This is useful for extracting type information about each route subtype. You can also use this to access the patterns for each route subtype, but its better to do so via `type.patterns` as you are guaranteed to get a normalized array of patterns even if in the definition you only configured a single item. +Returns the definition object you passed in when initialized the type. This is useful for extracting type information about each route member type. You can also use this to access the patterns for each route member type, but its better to do so via `type.patterns` as you are guaranteed to get a normalized array of patterns even if in the definition you only configured a single item. + +### `instance.context.rest` + +Any excess unmatched URL fragments will appear on the parsed `instance.context.rest` property. + +## Nested routes + +*superouter* has first class for nested routes. + +To create a nested type in superouter, you call `.create` on the constructor for the member type of the parent route. + +```typescript +const Root = superouter.create({ + Redirect: "/", + LoggedIn: (_: { organization_id: string }) => + "/:organization_id", +}); + +const LoggedIn = A.LoggedIn.create({ + Admin: "/admin", + Projects: "/projects", +}); + +const Admin = B.Admin.create({ + Organizations: "/organizations", + Roles: (_: { role_id: string }) => "/roles/:role_id", + Groups: (_: { group_id: string }) => "/groups/:group_id", +}); +``` + +Child route constructors know the type requirements for their parent routes, and inheirt the same requirements. + +So if we try to create an `Admin.Groups` route without specifying an `organization_id` we will get a type error: + +```typescript +// Typescript knows the parent route needs an org id: + +// TypeError: +Admin.Groups({ group_id: "contractors" }) + +// All good: +Admin.Groups({ + group_id: "contractors", organization_id: "harth" +}) +``` + +The type requirements cascade arbitrarily through any number of subroute types. + +### `toPath` + +`toPath` works just like it does on a normal top level route. This will produce a complete url path that could be added to `window.location.pathname` + +### `toLocalPath` + +We can also create just the local path fragment if we want: + +```typescript + +const example = Admin.Groups({ + group_id: "contractors", organization_id: "harth" +}) + +Admin.toLocalPath(example) +// => '/groups/contractors' +``` + +### `fromPath` + +`fromPath` works just like it does on a normal top level route. This will parse a complete url path that could be source from `window.location.pathname`. + +### `fromLocalPath` + +A local path fragment may not have sufficient information to satisfy the top level type constraints. So to parse a local path you need to provide an object of default values: + +```typescript +Admin.fromLocalPath( + '/groups/amazing' + , { organization_id: 'brilliant' } +) +// => +// Admin.Groups({ +// organization_id: 'brilliant', +// group_id: 'amazing' +// }) +``` + +Note we did not need to provide a default value for `group_id` or `role_id`, just parent route type constraints. ## Type helpers ### `Tag` -Extracts the possible tags from either a superouter sum type or a superouter instance type: +Extracts the possible tags from either a *superouter* sum type or a *superouter* instance type: ```typescript const a = Example.A({ a_id: "cool" }); // A union of all possible values for `Example` e.g. 'A' | 'B' | 'C' -type All = superouter.Value; +type All = superouter.Tag; // Exactly 'A' -type One = superouter.Value; +type One = superouter.Tag; ``` ### `Value` -Extracts the possible values from either a superouter sum type or a superouter instance type: +Extracts the possible values from + +- a route type +- a instance type +- a member type constructor type ```typescript const a = Example.A({ a_id: "cool" }); @@ -221,12 +467,15 @@ type All = superouter.Value; // Exactly { a_id: 'cool' } type One = superouter.Value; + +// Slightly broader: { a_id: string } +type OneAgain = superouter.Value ``` ### `Instance` ```typescript -const Example = superouter.type("Example", { +const Example = superouter.create({ A: (_: { a_id: string }) => `/a/:a_id`, B: (_: { b_id: string }) => `/b/:b_id`, C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], @@ -245,7 +494,7 @@ const yourFunction = (example: Instance) => example.tag; We were intending on doing exactly that, thinking it would be faster and support more features. But given `path-to-regexp` supports so many features, it would be difficult to determine the pattern match rank for all the variations. -Superouter instead has a very simple pattern language: you have literals and variables and patterns always accept extra segments. This makes for simpler ranking system. We're yet to need more power than that in route pattern matching for web apps. +*superouter* instead has a very simple pattern language: you have literals and variables and patterns always accept extra segments. This makes for a simpler ranking system. Finally it is also harder to get useful feedback about why something failed or didn't match when using Regular Expressions. Superouter has a very simple single pass parser that gives the user helpful feedback when a match couldn't be made. With regexp when something doesn't match you don't get a lot of insight into what you did wrong. @@ -258,103 +507,109 @@ While matching a path we increment a score value using the following rules: | Type of match | Score | | -------------------------- | ---------------- | | Extra fragments after path | `max(0,score-1)` | -| /:variable | `2` | -| /literal | `4` | +| `/:variable` | `2` | +| `/literal` | `4` | `fromPath` / `fromPathSafe` and `toPath` / `toPathSafe` use the same logic to pick the winning route / url. -### Supporting multiple patterns per sub type +### Supporting multiple patterns per member type ```js const Example = type("Example", { - A: (x: { a_id?: string }) => [`/example/a/:a`, `/example/a`], + A: (x: { a_id?: string }) => + [`/example/a/:a`, `/example/a`], B: (x: { b_id: string }) => `/example/b/:b`, }); ``` -Note in the above example we are returning a list of possible patterns for `A`: [`/example/a/:a`, `/example/a`]. This means if we hit `/example/a` and there is no binding for `/:a` we still get a match and superouter will return a value object of `{}` +Note in the above example we are returning a list of possible patterns for `A`: [`/example/a/:a`, `/example/a`]. This means if we hit `/example/a` and there is no binding for `/:a` we still get a match and *superouter* will return a value object of `{}` Because we are matching a pattern that has no bindings we make the type of `a_id` optional: `{ a_id?: string }`. Unfortunately we can't enforce this kind of relationship within typescript so you'll have to be diligent when defining your route defintions to keep your types and your patterns in sync. -### Integrating with Mithril's router +### Framework example: Integrating with Mithril's router -A route type returns its patterns and names via `type.patterns`, it also returns the original definition you passed in as `type.definition` +This isn't meant to be a plug n' play example, this is more a high level example to show what is possible. You could also use `Route.patterns` to built a traditional mithril `m.route` object. -We can use this metadata to both typecheck an index of `Route: Component` and then reference that index against its url patterns so we get an index of `Pattern: Component`. - -From there we can thread that through toe the framework API (in this case mithril's `m.route`) ```typescript -const Example = type("Example", { - Welcome: (x: { name?: string }) => [`/welcome/:name`, `/welcome`], - Login: (x: { error?: string }) => [`/login/error/:error`, `/login`], -}); +const Route = superouter.create({ -type Example = (typeof Example)["definition"]; + Welcome: (x: { name?: string }) => + [`/welcome/:name`, `/welcome`], -// Rough type definition of mithril component -type Component = (v: { attrs: T }) => { view: (v: { attrs: T }) => any }; + Login: (x: { error?: string }) => + [`/login/error/:error`, `/login`], + +}); -type Value = superouter.Value; +type Route = superouter.Instance -const WelcomeComp: Component> = () => ({ - view: (v) => `Welcome ${v.attrs.name ?? "User"}`, +// Rough type definition of mithril component +type Component = + (v: { attrs: T }) => + { view: (v: { attrs: T }) => any }; + +// Extract the component attributes from the route +type WelcomeAttrs = superouter.Value +type LoginAttrs = superouter.Value + +// Use them: +const WelcomeComp: Component = () => ({ + view: (v) => + `Welcome ${v.attrs.name ?? "User"}`, }); -const LoginComp: Component> = () => ({ + +// Use them: +const LoginComp: Component = () => ({ view: (v) => [ - v.attrs.error ? "There was an error: " + v.attrs.error : null, + v.attrs.error + ? "There was an error: " + v.attrs.error : null, "Please login using your username and password.", ], }); -type Components = { - [K in keyof D]: Component[0]>; -}; - -const Components: Components = { - Welcome: WelcomeComp, - Login: LoginComp, -}; - -m.route( - document.body, - "/", - Object.fromEntries( - Object.entries(Example.patterns).flatMap(([k, v]) => - v.map((v2) => [v2, Components[k]]) - ) - ) -); -``` - -Effectively we're zipping together the patterns with their corresponding mithril components. We're also using the route definition to parameterize the mithril components. So if we change our route definition without updating our component we will get a useful type error. -Note the same is possible for any framework e.g. for React Router, but our iteration would instead return the contract expected there e.g. +// parse the initial route +let route = Route.fromPath(window.location.pathname); -```typescript -{ - path: pattern, - element: ReactComponent, +window.history.onpopstate = () => { + // parse subsequent routes + route = Route.fromPath(window.location.pathname) + m.redraw() } -``` - -The convention of `/name/:pattern` is near universal. -### Nested / Dynamic routes - -superouter is stateless, it never touches your browser `history` object or peeks into `window.location`. It is up to you what URL `path` you pass in to parse. - -This means you can have different superouter instances aribtarily at different depths within your application, and you just need to either include the full prefix path in the definition, or remove the redundant repeating part of the pathname that you pass in for a nested route. +// a util you can extend to generate the attrs for an anchor tag +let link = (options: { route: Route, replace?: boolean }) => + ({ + onclick(e){ + e.preventDefault() + let method = replace ? 'replaceState' : 'pushState' + window.history[method]('', null, Route.toPath(route)) + }, + href: Route.toPath(route) + }) + +// Usage: +// m('a', link({ route: Route.Welcome({ name: 'James' }) }), 'Home') + +m.mount(document.body, () => { + view: () => + Route.match( route, { + Welcome: attrs => m(WelcomeComp, attrs), + Login: attrs => m(LoginComp, attrs) + }) +}) +``` -As to how you should bind that into your framework of choice, that is up to you. All superouter does is help with the representation of data and the corresponding type checks and information. +This is just one interpretation. You really are in full control, all superouter does is encode/decode route patterns/state. The way you integrate it into your own framework is up to you. ## ESLint / Typescript complaining about no-unused-vars in route definitions You can optionally return the input argument as part of the tuple to silence this warning "natively" e.g. ```typescript -superouter.type("Example", { - A: (x: { a_id: string }) => `/:a_id`, +superouter.create({ + A: (x: { a_id: string }) => [x, [`/:a_id`]], }); ``` @@ -374,39 +629,7 @@ Alternatively you can name the var `_` and then tell ESLint to never warn about If you have that configured, you can skip returning the input argument which is equivalent but arguably cleaner: ```typescript -superouter.type("Example", { +superouter.create({ A: (_: { a_id: string }) => `/:a_id`, }); ``` - -## Enforcing route definitions that can handle any path segment - -`superouter` deliberately allows you to create route definitions that assume specific path literals without a fallback. This avoids a lot of needless checking in nested routes when it wouldn't make sense for the literal prefix to exist. - -But if you would like to guarantee your code can handle arbitrary paths, simply call `fromPath('/')` immediately after defintion. Then your code will throw if the definition is not total. - -```typescript -const Example = superouter.type("Example", { - A: (_: { a_id: string }) => `/a/:a_id`, - B: (_: { b_id: string }) => `/b/:b_id`, - C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], -}); - -// will throw, doesn't match any case -Example.fromPath("/"); -``` - -vs - -```typescript -const Example = superouter.type("Example", { - A: (_: { a_id: string }) => `/a/:a_id`, - B: (_: { b_id: string }) => `/b/:b_id`, - C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], - Default: (_: {}) => `/`, -}); - -// will not throw, matches 'Default' case -Example.fromPath("/"); -// => { type: 'Example', tag: 'Default', value: {} } -``` diff --git a/test/fromPath.ts b/test/fromPath.ts index 981d266..06ad3a2 100644 --- a/test/fromPath.ts +++ b/test/fromPath.ts @@ -1,135 +1,153 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import test from "node:test"; +import {describe, it} from "node:test"; import assert from "node:assert"; import { safe } from "../lib/fromPath.js"; -test("fromPath: simple", () => { - const inputs = [ - ["/welcome/james", "/:name/:nam"], - ["/welcome/james", "/welcome/:nam"], - ["/welcome/james/extra", "/welcome/:nam"], - ] as const; - - const results = inputs.map(([path, pattern]) => safe(path, pattern)); - - assert( - results.filter((x) => x.tag == "Left").length === 0, - "All can be parsed" - ); - - const best = results - .flatMap((x) => (x.tag == "Right" ? [x] : [])) - .sort((a, b) => b.value.score - a.value.score) - .map((x) => x.value)[0]; - - assert(best.rest == "/", "most specific wins"); - - assert.deepEqual(best.value, { nam: "james" }, "Expected parsed URL"); -}); - -test("fromPath: rest", () => { - const inputs = [ - ["/welcome/james", "/welcome/:name"], - ["/welcome/james/extra", "/welcome/:name"], - ] as const; - - const results = inputs.map(([path, pattern]) => safe(path, pattern)); - - assert( - results.filter((x) => x.tag == "Left").length === 0, - "All can be parsed" - ); - - const sorted = results - .flatMap((x) => (x.tag == "Right" ? [x] : [])) - .sort((a, b) => b.value.score - a.value.score) - .map((x) => x.value); - - assert(sorted[0].rest == "/", "most specific wins"); - assert(sorted[1].rest == "/extra", "rest has expected value"); - assert.deepEqual( - sorted[1].value, - { name: "james" }, - "rest has expected value" - ); -}); - -test("fromPath: garbage", () => { - assert.deepEqual(safe("", ""), { - type: "Either", - tag: "Right", - value: { rest: "/", value: {}, score: 0 }, - }); - assert.deepEqual(safe("////", ""), { - type: "Either", - tag: "Right", - value: { rest: "/", value: {}, score: 0 }, +describe('fromPath', () => { + it("simple", () => { + const inputs = [ + ["/welcome/james", "/:name/:nam"], + ["/welcome/james", "/welcome/:nam"], + ["/welcome/james/extra", "/welcome/:nam"], + ] as const; + + const results = inputs.map(([path, pattern]) => safe(path, pattern)); + + assert( + results.filter((x) => x.tag == "Left").length === 0, + "All can be parsed" + ); + + const best = results + .flatMap((x) => (x.tag == "Right" ? [x] : [])) + .sort((a, b) => b.value.score - a.value.score) + .map((x) => x.value)[0]; + + assert(best.rest == "", "most specific wins"); + + assert.deepEqual(best.value, { nam: "james" }, "Expected parsed URL"); }); - assert.deepEqual(safe("", "////////"), { - type: "Either", - tag: "Right", - value: { rest: "/", value: {}, score: 0 }, + + it("rest", () => { + const inputs = [ + ["/welcome/james", "/welcome/:name"], + ["/welcome/james/extra", "/welcome/:name"], + ] as const; + + const results = inputs.map(([path, pattern]) => safe(path, pattern)); + + assert( + results.filter((x) => x.tag == "Left").length === 0, + "All can be parsed" + ); + + const sorted = results + .flatMap((x) => (x.tag == "Right" ? [x] : [])) + .sort((a, b) => b.value.score - a.value.score) + .map((x) => x.value); + + assert.equal(sorted[0].rest, "", "most specific wins"); + assert.equal(sorted[1].rest, "extra", "rest has expected value"); + assert.deepEqual( + sorted[1].value, + { name: "james" }, + "rest has expected value" + ); }); - assert.deepEqual(safe("///////", "////////"), { - type: "Either", - tag: "Right", - value: { rest: "/", value: {}, score: 0 }, + + it("garbage", () => { + assert.deepEqual(safe("", ""), { + type: "Either", + tag: "Right", + value: { rest: "", value: {}, score: 0 }, + }); + assert.deepEqual(safe("////", ""), { + type: "Either", + tag: "Right", + value: { rest: "", value: {}, score: 0 }, + }); + assert.deepEqual(safe("", "////////"), { + type: "Either", + tag: "Right", + value: { rest: "", value: {}, score: 0 }, + }); + assert.deepEqual(safe("///////", "////////"), { + type: "Either", + tag: "Right", + value: { rest: "", value: {}, score: 0 }, + }); + + assert.deepEqual(safe("a", ":a"), { + type: "Either", + tag: "Right", + value: { rest: "", value: { a: "a" }, score: 1 }, + }); + + assert.deepEqual(safe(":::::", ":a"), { + type: "Either", + tag: "Right", + value: { rest: "", value: { a: ":::::" }, score: 1 }, + }); + + // @ts-expect-error + assert.throws(() => safe()); + // @ts-expect-error + assert.throws(() => safe("")); + // @ts-expect-error + assert.throws(() => safe(null, "")); }); - - assert.deepEqual(safe("a", ":a"), { - type: "Either", - tag: "Right", - value: { rest: "/", value: { a: "a" }, score: 1 }, - }); - - assert.deepEqual(safe(":::::", ":a"), { - type: "Either", - tag: "Right", - value: { rest: "/", value: { a: ":::::" }, score: 1 }, + + it("complex", () => { + const inputs = [ + ["", "/a"], + ["/a", ""], + ["/a//////", "a"], + ["/welcome/james", "/:a/:b/:c/d/e/f/:g"], + ["/welcome/james/you/d/e/f/cool/and/something/extra", "/:a/:b/:c/d/e/f/:g"], + ] as const; + + const results = inputs.map(([path, pattern]) => safe(path, pattern)); + + const failures = results.flatMap((x) => (x.tag === "Left" ? [x.value] : [])); + const success = results.flatMap((x) => (x.tag === "Right" ? [x.value] : [])); + + assert.equal(failures.length, 2); + { + const [fail] = failures; + + assert.match(fail.message, /literal path/); + assert.match(fail.message, /\/a/); + } + { + const [_, fail] = failures; + + assert.match(fail.message, /Expected binding/); + assert.match(fail.message, /:c/); + } + assert.deepEqual(success, [ + { rest: "a", value: {}, score: 0 }, + { rest: "", value: {}, score: 3 }, + { + rest: "and/something/extra", + value: { a: "welcome", b: "james", c: "you", g: "cool" }, + score: 19, + }, + ]); }); - - // @ts-expect-error - assert.throws(() => safe()); - // @ts-expect-error - assert.throws(() => safe("")); - // @ts-expect-error - assert.throws(() => safe(null, "")); -}); - -test("fromPath: complex", () => { - const inputs = [ - ["", "/a"], - ["/a", ""], - ["/a//////", "a"], - ["/welcome/james", "/:a/:b/:c/d/e/f/:g"], - ["/welcome/james/you/d/e/f/cool/and/something/extra", "/:a/:b/:c/d/e/f/:g"], - ] as const; - - const results = inputs.map(([path, pattern]) => safe(path, pattern)); - - const failures = results.flatMap((x) => (x.tag === "Left" ? [x.value] : [])); - const success = results.flatMap((x) => (x.tag === "Right" ? [x.value] : [])); - - assert.equal(failures.length, 2); - { - const [fail] = failures; - - assert.match(fail.message, /literal path/); - assert.match(fail.message, /\/a/); - } - { - const [_, fail] = failures; - - assert.match(fail.message, /Expected binding/); - assert.match(fail.message, /:c/); - } - assert.deepEqual(success, [ - { rest: "/a", value: {}, score: 0 }, - { rest: "/", value: {}, score: 3 }, + + it("odin", () => { + + { + const res = safe("/data/schedules", "/data/schedules/:schedule_id") + + assert( res.tag == 'Left') + assert.match(res.value.message, /variable ':schedule_id'/) + } + { - rest: "/and/something/extra", - value: { a: "welcome", b: "james", c: "you", g: "cool" }, - score: 19, - }, - ]); -}); + const res = safe('/data/schedules', '/data/schedules/create') + assert( res.tag == 'Left') + assert.match(res.value.message, /literal path segment '\/create'/) + } + }) +}) diff --git a/test/index.ts b/test/index.ts index 4ded8d7..45fa5a6 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,141 +1,254 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import test from "node:test"; +import { describe, it } from "node:test"; import assert from "node:assert"; import * as superouter from "../lib/index.js"; -test("index: basic", () => { - const Example = superouter.type("Example", { - A: (_: { a_id: string }) => `/a/:a_id`, - B: (_: { b_id: string }) => `/b/:b_id`, +const defaultContext = { + rest: "", +}; + +describe("index", () => { + it("basic", () => { + const Example = superouter.create("Example", { + A: (_: { a_id: string }) => `/a/:a_id`, + B: (_: { b_id: string }) => `/b/:b_id`, + }); + + assert.deepEqual(Example.A({ a_id: "hello" }), { + type: "Example", + tag: "A", + value: { a_id: "hello" }, + context: defaultContext, + }); + assert.deepEqual(Example.B({ b_id: "hello" }), { + type: "Example", + tag: "B", + value: { b_id: "hello" }, + context: defaultContext, + }); + + assert.equal(Example.definition.A({ a_id: "" }), "/a/:a_id"); + + assert.deepEqual(Example.patterns, { + A: ["/a/:a_id"], + B: ["/b/:b_id"], + }); + + assert.equal(Example.isA(Example.A({ a_id: "cool" })), true); + assert.equal(Example.isB(Example.A({ a_id: "cool" })), false); + + assert.equal( + Example.getA("default", (x) => x.a_id, Example.A({ a_id: "cool" })), + "cool" + ); + assert.equal( + Example.getA("default", (x) => x.a_id, Example.B({ b_id: "cool" })), + "default" + ); }); - assert.deepEqual(Example.A({ a_id: "hello" }), { - type: "Example", - tag: "A", - value: { a_id: "hello", rest: '/' }, + it("toPath", () => { + const Example = superouter.create("Example", { + A: (_: { a_id: string }) => `/a/:a_id`, + B: (_: { b_id: string }) => `/b/:b_id`, + C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], + }); + assert.equal(Example.toPath(Example.A({ a_id: "cool" })), "/a/cool"); + assert.throws( + () => Example.toPath(Example.A({ a_id: "" })), + /Could not build pattern \/a\/:a_id/ + ); + + assert.equal(Example.toPath(Example.C({})), "/c"); + assert.equal(Example.toPath(Example.C({ c_id: undefined })), "/c"); + assert.equal( + Example.toPath(Example.C({ c_id: null as any as string })), + "/c" + ); + assert.equal( + Example.toPath(Example.C({ c_id: true as any as string })), + "/c/true" + ); }); - assert.deepEqual(Example.B({ b_id: "hello" }), { - type: "Example", - tag: "B", - value: { b_id: "hello", rest: '/' }, + + it("fromPath", () => { + const Example = superouter.create("Example", { + A: (_: { a_id: string }) => `/a/:a_id`, + B: (_: { b_id: string }) => `/b/:b_id`, + C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], + }); + + assert.throws(() => Example.fromPath("/"), /Expected literal/); + assert.deepEqual( + Example.C({ c_id: "cool" }, { rest: "" }), + Example.fromPath("/c/cool") + ); + assert.deepEqual( + Example.C({ c_id: "cool" }, { rest: "" }), + Example.fromPath("/c/cool") + ); + assert.deepEqual(Example.C({ c_id: "cool" }), Example.fromPath("/c/cool")); + assert.deepEqual(Example.C({}), Example.fromPath("/c")); + + assert.throws(() => Example.fromPath("/a"), /Expected binding/); + + { + const res = Example.fromPathSafe("/a"); + assert(res.tag == "Left"); + assert.match(res.value.message, /Expected binding/); + } }); - assert.equal(Example.definition.A({ a_id: "" }), "/a/:a_id"); + it("anonymous routes", () => { + const Main = superouter.create({ + A: (_: { a_id: string }) => `/a/:a_id`, + B: (_: { b_id: string }) => `/b/:b_id`, + C: `/c`, + }); + + assert.deepEqual(Main.A({ a_id: "wow" }), { + type: "Main", + tag: "A", + value: { a_id: "wow" }, + context: defaultContext, + }); + }); - assert.deepEqual(Example.patterns, { - A: ["/a/:a_id"], - B: ["/b/:b_id"], + it("match", () => { + const Example = superouter.create("Example", { + A: (_: { a_id: string }) => `/a/:a_id`, + B: (_: { b_id: string }) => `/b/:b_id`, + C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], + }); + + const f = (route: superouter.Instance) => + Example.match(route, { + A: ({ a_id }) => Number(a_id), + B: ({ b_id }) => Number(b_id), + C: ({ c_id }) => (c_id ? Number(c_id) : 0), + }); + + assert.equal(f(Example.A({ a_id: "4" })), 4); + assert.equal(f(Example.B({ b_id: "2" })), 2); + assert.equal(f(Example.C({ c_id: "100" })), 100); + assert.equal(f(Example.C({})), 0); + + { + const _ = Example.otherwise(["B", "C"]); + + const g = (r: superouter.Instance) => + Example.match(r, { + A: () => 1, + ..._(() => -1), + }); + + assert.equal(g(Example.A({ a_id: "cool" })), 1); + assert.equal(g(Example.B({ b_id: "cool" })), -1); + assert.equal(g(Example.C({ c_id: "cool" })), -1); + } + { + const _ = Example.otherwise(); + + const g = (r: superouter.Instance) => + Example.match(r, { + ..._(() => -1), + A: () => 1, + }); + + assert.equal(g(Example.A({ a_id: "cool" })), 1); + assert.equal(g(Example.B({ b_id: "cool" })), -1); + assert.equal(g(Example.C({ c_id: "cool" })), -1); + } }); - assert.equal(Example.isA(Example.A({ a_id: "cool" })), true); - assert.equal(Example.isB(Example.A({ a_id: "cool" })), false); - - assert.equal( - Example.getA("default", (x) => x.a_id, Example.A({ a_id: "cool" })), - "cool" - ); - assert.equal( - Example.getA("default", (x) => x.a_id, Example.B({ b_id: "cool" })), - "default" - ); -}); + it("silly", () => { + // todo-james should this throw? + const Example = superouter.create("Example", {}); -test("index: toPath", () => { - const Example = superouter.type("Example", { - A: (_: { a_id: string }) => `/a/:a_id`, - B: (_: { b_id: string }) => `/b/:b_id`, - C: (_: { c_id?: string }) => [`/c`, '/c/:c_id'] - }); - assert.equal( - Example.toPath( Example.A({ a_id: 'cool' })), '/a/cool' - ) - assert.throws( - () => Example.toPath( Example.A({ a_id: '' })), - /Could not build pattern/ - ) - - assert.equal( - Example.toPath( Example.C({}) ), '/c' - ) - assert.equal( - Example.toPath( Example.C({ c_id: undefined}) ), '/c' - ) - assert.equal( - Example.toPath( Example.C({ c_id: null as any as string }) ), '/c' - ) - assert.equal( - Example.toPath( Example.C({ c_id: true as any as string }) ), '/c/true' - ) -}); -test("index: fromPath", () => { - const Example = superouter.type("Example", { - A: (_: { a_id: string }) => `/a/:a_id`, - B: (_: { b_id: string }) => `/b/:b_id`, - C: (_: { c_id?: string }) => [`/c`, '/c/:c_id'] + assert.throws(() => Example.fromPath("/"), /Could not parse url/); }); - assert.throws(() => Example.fromPath('/'), /Expected literal/) - assert.deepEqual( - Example.C({ c_id: 'cool', rest: '/' }), Example.fromPath('/c/cool') - ) - assert.deepEqual( - Example.C({ c_id: 'cool', rest: '' }), Example.fromPath('/c/cool') - ) - assert.deepEqual( - Example.C({ c_id: 'cool' }), Example.fromPath('/c/cool') - ) - assert.deepEqual( - Example.C({}), Example.fromPath('/c') - ) - - assert.throws(() => Example.fromPath('/a'), /Expected binding/) - - { - const res = Example.fromPathSafe('/a') - assert(res.tag == 'Left') - assert.match(res.value.message, /Expected binding/) - } -}); - -test('index: match', () => { - const Example = superouter.type("Example", { - A: (_: { a_id: string }) => `/a/:a_id`, - B: (_: { b_id: string }) => `/b/:b_id`, - C: (_: { c_id?: string }) => [`/c`, '/c/:c_id'], + // leave undocumented for now, may not make it into final v1 + it("fromPathOr", () => { + const Example = superouter.create("Example", { + A: (_: { a_id: string }) => `/a/:a_id`, + B: (_: { b_id: string }) => `/b/:b_id`, + C: (_: { c_id?: string }) => [`/c`, "/c/:c_id"], + }); + + { + const res = Example.fromPathOr(() => Example.A({ a_id: "cool" }), "/"); + assert.deepEqual(res, Example.A({ a_id: "cool" })); + } + + { + const res = Example.fromPathOr( + () => Example.A({ a_id: "cool" }), + "/a/notcool" + ); + assert.deepEqual(res, Example.A({ a_id: "notcool" })); + } }); - const f = (route: superouter.Instance) => Example.match( route, { - A: ({ a_id }) => Number(a_id), - B: ({ b_id }) => Number(b_id), - C: ({ c_id }) => c_id ? Number(c_id) : 0 - }) - - assert.equal(f(Example.A({ a_id: '4' })), 4) - assert.equal(f(Example.B({ b_id: '2' })), 2) - assert.equal(f(Example.C({ c_id: '100' })), 100) - assert.equal(f(Example.C({})), 0) - - const _ = Example.otherwise(['B', 'C']) - - const g = (r: superouter.Instance) => Example.match(r, { - A: () => 1, - ..._(() => -1) - }) - - assert.equal( - g(Example.A({ a_id: 'cool' })), 1 - ) - assert.equal( - g(Example.B({ b_id: 'cool' })), -1 - ) - assert.equal( - g(Example.C({ c_id: 'cool' })), -1 - ) -}) - -test('index: silly', () => { - // todo-james should this throw? - const Example = superouter.type('Example', {}) - - assert.throws(() => Example.fromPath('/'), /Could not parse url/) -}) \ No newline at end of file + it("rest", () => { + const Odin = superouter.create("Odin", { + Home: (_: Record) => "/", + Organization: (_: Record) => "/admin/organizations", + }); + + assert.equal( + Odin.toPath(Odin.Organization({}, { rest: "1" })), + "/admin/organizations/1" + ); + assert.equal( + Odin.toPath(Odin.Organization({}, { rest: "/1" })), + "/admin/organizations/1" + ); + assert.equal( + Odin.toPath(Odin.Organization({}, { rest: "/1/" })), + "/admin/organizations/1" + ); + assert.equal( + Odin.toPath(Odin.Organization({}, { rest: "1/" })), + "/admin/organizations/1" + ); + + const Orgs = superouter.create("Orgs", { + List: (_: { organization_id: string }) => `/:organization_id`, + Group: (_: { organization_id: string; group_id: string }) => + `/:organization_id/groups/:group_id`, + }); + + { + const parentPath = Odin.toPath(Odin.Organization({}, { rest: "1" })); + + const child = Orgs.fromPath( + parentPath.replace(`/admin/organizations`, "") + ); + + assert.equal(Orgs.toPath(child), "/1"); + } + { + const originalUrl = `/admin/organizations/1/groups/2`; + + const originalRoute = Odin.fromPath(originalUrl); + + assert.deepEqual( + originalRoute, + Odin.Organization({}, { rest: "1/groups/2" }) + ); + + const parentPath = Odin.toPath(originalRoute); + + assert.equal(originalUrl, parentPath); + + const prefix = `/admin/organizations`; + + const child = Orgs.fromPath(parentPath.replace(prefix, "")); + + assert.equal(Orgs.toPath(child), "/1/groups/2"); + + assert.equal(prefix + Orgs.toPath(child), originalUrl); + } + }); +}); diff --git a/test/nested.ts b/test/nested.ts new file mode 100644 index 0000000..344a0d2 --- /dev/null +++ b/test/nested.ts @@ -0,0 +1,94 @@ +import assert from "node:assert"; +import * as superouter from "../lib"; +import { describe, it } from "node:test"; + +describe("nested", () => { + const A = superouter.create({ + Redirect: "/", + LoggedIn: (_: { organization_id: string }) => "/:organization_id", + }); + + const B = A.LoggedIn.create({ + Admin: "/admin", + Projects: "/projects", + }); + + const C = B.Admin.create({ + Organizations: "/organizations", + Roles: (_: { role_id: string }) => "/roles/:role_id", + Groups: (_: { group_id: string }) => "/groups/:group_id", + }); + + const a = A.LoggedIn({ organization_id: "cool" }); + const b = B.Admin({ organization_id: "cool" }); + const c = C.Roles({ organization_id: "nice", role_id: "basic" }); + it("constructors", () => { + + assert.deepEqual(a, { + type: "Main", + tag: "LoggedIn", + value: { organization_id: "cool" }, + context: { rest: "" }, + }); + + assert.deepEqual(b, { + type: "Main.LoggedIn", + tag: "Admin", + value: { organization_id: "cool" }, + context: { rest: "" }, + }); + + + assert.deepEqual(c, { + type: "Main.LoggedIn.Admin", + tag: "Roles", + value: { organization_id: "nice", role_id: "basic" }, + context: { rest: "" }, + }); + + }) + + it('toPath / toLocalPath', () => { + assert.equal(`/cool`, A.toPath(a)); + assert.equal(`/cool`, A.toLocalPath(a)); + + assert.equal(`/cool/admin`, B.toPath(b)); + assert.equal(`/admin`, B.toLocalPath(b)); + + assert.equal(`/nice/admin/roles/basic`, C.toPath(c)); + assert.equal(`/roles/basic`, C.toLocalPath(c)); + }) + + it('fromPath / fromLocalPath', () => { + + assert.deepEqual(A.fromPath("/cool/admin/roles/basic"), { + type: "Main", + tag: "LoggedIn", + value: { organization_id: "cool" }, + context: { rest: "admin/roles/basic" }, + }); + assert.deepEqual(B.fromPath("/cool/admin/roles/basic"), { + type: "Main.LoggedIn", + tag: "Admin", + value: { organization_id: "cool" }, + context: { rest: "roles/basic" }, + }); + assert.deepEqual(C.fromPath("/cool/admin/roles/basic"), { + type: "Main.LoggedIn.Admin", + tag: "Roles", + value: { role_id: "basic", organization_id: "cool" }, + context: { rest: "" }, + }); + + assert.deepEqual( + C.fromLocalPath("/roles/wizard", { organization_id: "nice" }), + { + type: "Main.LoggedIn.Admin", + tag: "Roles", + value: { organization_id: "nice", role_id: "wizard" }, + context: { rest: "" }, + } + ); + + }) +});