@vielzeug/wayfinder 26.10.0 → 26.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,4 +55,4 @@ await router.navigate({ name: 'dashboard.settings' });
55
55
 
56
56
  ## License
57
57
 
58
- MIT © [Helmuth Saatkamp](https://github.com/helmuthdu) — part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
58
+ MIT © [Helmuth Saatkamp](https://github.com/helmuthdu): part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
@@ -1 +1 @@
1
- {"version":3,"file":"context.cjs","names":[],"sources":["../src/context.ts"],"sourcesContent":["import { error as logError } from './_dev';\nimport { WayfinderApiError } from './errors';\nimport type {\n Middleware,\n NavigationStatus,\n ResolvedQueryParams,\n RouteBranchDef,\n RouteContext,\n RouteLocation,\n RouteMatch,\n RouteMatchBranch,\n RouteParams,\n RouterErrorContext,\n RouteState,\n RouteTable,\n} from './types';\n\n// ─── Route state ──────────────────────────────────────────────────────────────\n\n/** No defensive deep-copy — RouteState is typed readonly; callers always pass fresh objects. */\nexport function createRouteState(input: {\n error?: unknown;\n location: RouteLocation;\n matches: RouteMatchBranch;\n status: NavigationStatus;\n}): RouteState {\n return {\n error: input.error,\n location: input.location,\n matches: input.matches,\n status: input.status,\n };\n}\n\n/**\n * Build a RouteMatchBranch from compiled defs and data results.\n */\nexport function buildMatchBranch(\n branchDefs: readonly RouteBranchDef[],\n params: RouteParams,\n pathname: string,\n dataResults: unknown[],\n): RouteMatchBranch {\n return branchDefs.map(\n (def, i): RouteMatch => ({\n data: dataResults[i],\n name: def.name,\n params: { ...params },\n pathname,\n }),\n );\n}\n\n// ─── Context factory ──────────────────────────────────────────────────────────\n\nexport function createRouteContext<TRoutes extends RouteTable>(\n location: RouteLocation,\n resolvedQuery: ResolvedQueryParams,\n params: RouteParams,\n matches: RouteMatchBranch,\n navigate: RouteContext<RouteParams, TRoutes>['navigate'],\n): RouteContext<RouteParams, TRoutes> {\n return {\n hash: location.hash,\n historyState: location.historyState,\n locals: {},\n matches,\n navigate,\n params,\n pathname: location.pathname,\n query: resolvedQuery,\n };\n}\n\n// ─── Middleware pipeline ──────────────────────────────────────────────────────\n\nexport async function executeMiddlewarePipeline<TRoutes extends RouteTable>(\n context: RouteContext<RouteParams, TRoutes>,\n middleware: readonly Middleware<TRoutes>[],\n terminal: () => Promise<void>,\n wrapMiddlewareError?: (error: unknown) => unknown,\n): Promise<boolean> {\n let terminalRan = false;\n\n async function dispatch(index: number): Promise<void> {\n if (index < middleware.length) {\n let called = false;\n\n try {\n await middleware[index]?.(context, async () => {\n if (called) throw new WayfinderApiError('next() called multiple times');\n\n called = true;\n await dispatch(index + 1);\n });\n } catch (error) {\n throw wrapMiddlewareError?.(error) ?? error;\n }\n\n return;\n }\n\n terminalRan = true;\n await terminal();\n }\n\n await dispatch(0);\n\n return terminalRan;\n}\n\n// ─── Error reporting ──────────────────────────────────────────────────────────\n\nexport function reportError(\n error: unknown,\n context: RouterErrorContext,\n onError?: (e: unknown, ctx: RouterErrorContext) => void,\n): void {\n if (onError) {\n onError(error, context);\n\n return;\n }\n\n logError('Unhandled router error. Provide an onError callback to handle errors explicitly:', error);\n}\n"],"mappings":"sDAoBA,SAAgB,iBAAiB,EAKlB,CACb,MAAO,CACL,MAAO,EAAM,MACb,SAAU,EAAM,SAChB,QAAS,EAAM,QACf,OAAQ,EAAM,MAChB,CACF,CAKA,SAAgB,iBACd,EACA,EACA,EACA,EACkB,CAClB,OAAO,EAAW,KACf,EAAK,KAAmB,CACvB,KAAM,EAAY,GAClB,KAAM,EAAI,KACV,OAAQ,CAAE,GAAG,CAAO,EACpB,UACF,EACF,CACF,CAIA,SAAgB,mBACd,EACA,EACA,EACA,EACA,EACoC,CACpC,MAAO,CACL,KAAM,EAAS,KACf,aAAc,EAAS,aACvB,OAAQ,CAAC,EACT,UACA,WACA,SACA,SAAU,EAAS,SACnB,MAAO,CACT,CACF,CAIA,eAAsB,0BACpB,EACA,EACA,EACA,EACkB,CAClB,IAAI,EAAc,GAElB,eAAe,SAAS,EAA8B,CACpD,GAAI,EAAQ,EAAW,OAAQ,CAC7B,IAAI,EAAS,GAEb,GAAI,CACF,MAAM,EAAW,EAAM,GAAG,EAAS,SAAY,CAC7C,GAAI,EAAQ,MAAM,IAAI,EAAA,kBAAkB,8BAA8B,EAEtE,EAAS,GACT,MAAM,SAAS,EAAQ,CAAC,CAC1B,CAAC,CACH,OAAS,EAAO,CACd,MAAM,IAAsB,CAAK,GAAK,CACxC,CAEA,MACF,CAEA,EAAc,GACd,MAAM,EAAS,CACjB,CAIA,OAFA,MAAM,SAAS,CAAC,EAET,CACT,CAIA,SAAgB,YACd,EACA,EACA,EACM,CACN,GAAI,EAAS,CACX,EAAQ,EAAO,CAAO,EAEtB,MACF,CAGF"}
1
+ {"version":3,"file":"context.cjs","names":[],"sources":["../src/context.ts"],"sourcesContent":["import { error as logError } from './_dev';\nimport { WayfinderApiError } from './errors';\nimport type {\n Middleware,\n NavigationStatus,\n ResolvedQueryParams,\n RouteBranchDef,\n RouteContext,\n RouteLocation,\n RouteMatch,\n RouteMatchBranch,\n RouteParams,\n RouterErrorContext,\n RouteState,\n RouteTable,\n} from './types';\n\n// ─── Route state ──────────────────────────────────────────────────────────────\n\n/** No defensive deep-copy: RouteState is typed readonly; callers always pass fresh objects. */\nexport function createRouteState(input: {\n error?: unknown;\n location: RouteLocation;\n matches: RouteMatchBranch;\n status: NavigationStatus;\n}): RouteState {\n return {\n error: input.error,\n location: input.location,\n matches: input.matches,\n status: input.status,\n };\n}\n\n/**\n * Build a RouteMatchBranch from compiled defs and data results.\n */\nexport function buildMatchBranch(\n branchDefs: readonly RouteBranchDef[],\n params: RouteParams,\n pathname: string,\n dataResults: unknown[],\n): RouteMatchBranch {\n return branchDefs.map(\n (def, i): RouteMatch => ({\n data: dataResults[i],\n name: def.name,\n params: { ...params },\n pathname,\n }),\n );\n}\n\n// ─── Context factory ──────────────────────────────────────────────────────────\n\nexport function createRouteContext<TRoutes extends RouteTable>(\n location: RouteLocation,\n resolvedQuery: ResolvedQueryParams,\n params: RouteParams,\n matches: RouteMatchBranch,\n navigate: RouteContext<RouteParams, TRoutes>['navigate'],\n): RouteContext<RouteParams, TRoutes> {\n return {\n hash: location.hash,\n historyState: location.historyState,\n locals: {},\n matches,\n navigate,\n params,\n pathname: location.pathname,\n query: resolvedQuery,\n };\n}\n\n// ─── Middleware pipeline ──────────────────────────────────────────────────────\n\nexport async function executeMiddlewarePipeline<TRoutes extends RouteTable>(\n context: RouteContext<RouteParams, TRoutes>,\n middleware: readonly Middleware<TRoutes>[],\n terminal: () => Promise<void>,\n wrapMiddlewareError?: (error: unknown) => unknown,\n): Promise<boolean> {\n let terminalRan = false;\n\n async function dispatch(index: number): Promise<void> {\n if (index < middleware.length) {\n let called = false;\n\n try {\n await middleware[index]?.(context, async () => {\n if (called) throw new WayfinderApiError('next() called multiple times');\n\n called = true;\n await dispatch(index + 1);\n });\n } catch (error) {\n throw wrapMiddlewareError?.(error) ?? error;\n }\n\n return;\n }\n\n terminalRan = true;\n await terminal();\n }\n\n await dispatch(0);\n\n return terminalRan;\n}\n\n// ─── Error reporting ──────────────────────────────────────────────────────────\n\nexport function reportError(\n error: unknown,\n context: RouterErrorContext,\n onError?: (e: unknown, ctx: RouterErrorContext) => void,\n): void {\n if (onError) {\n onError(error, context);\n\n return;\n }\n\n logError('Unhandled router error. Provide an onError callback to handle errors explicitly:', error);\n}\n"],"mappings":"sDAoBA,SAAgB,iBAAiB,EAKlB,CACb,MAAO,CACL,MAAO,EAAM,MACb,SAAU,EAAM,SAChB,QAAS,EAAM,QACf,OAAQ,EAAM,MAChB,CACF,CAKA,SAAgB,iBACd,EACA,EACA,EACA,EACkB,CAClB,OAAO,EAAW,KACf,EAAK,KAAmB,CACvB,KAAM,EAAY,GAClB,KAAM,EAAI,KACV,OAAQ,CAAE,GAAG,CAAO,EACpB,UACF,EACF,CACF,CAIA,SAAgB,mBACd,EACA,EACA,EACA,EACA,EACoC,CACpC,MAAO,CACL,KAAM,EAAS,KACf,aAAc,EAAS,aACvB,OAAQ,CAAC,EACT,UACA,WACA,SACA,SAAU,EAAS,SACnB,MAAO,CACT,CACF,CAIA,eAAsB,0BACpB,EACA,EACA,EACA,EACkB,CAClB,IAAI,EAAc,GAElB,eAAe,SAAS,EAA8B,CACpD,GAAI,EAAQ,EAAW,OAAQ,CAC7B,IAAI,EAAS,GAEb,GAAI,CACF,MAAM,EAAW,EAAM,GAAG,EAAS,SAAY,CAC7C,GAAI,EAAQ,MAAM,IAAI,EAAA,kBAAkB,8BAA8B,EAEtE,EAAS,GACT,MAAM,SAAS,EAAQ,CAAC,CAC1B,CAAC,CACH,OAAS,EAAO,CACd,MAAM,IAAsB,CAAK,GAAK,CACxC,CAEA,MACF,CAEA,EAAc,GACd,MAAM,EAAS,CACjB,CAIA,OAFA,MAAM,SAAS,CAAC,EAET,CACT,CAIA,SAAgB,YACd,EACA,EACA,EACM,CACN,GAAI,EAAS,CACX,EAAQ,EAAO,CAAO,EAEtB,MACF,CAGF"}
package/dist/context.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Middleware, NavigationStatus, ResolvedQueryParams, RouteBranchDef, RouteContext, RouteLocation, RouteMatchBranch, RouteParams, RouterErrorContext, RouteState, RouteTable } from './types';
2
- /** No defensive deep-copy — RouteState is typed readonly; callers always pass fresh objects. */
2
+ /** No defensive deep-copy: RouteState is typed readonly; callers always pass fresh objects. */
3
3
  export declare function createRouteState(input: {
4
4
  error?: unknown;
5
5
  location: RouteLocation;
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,mBAAmB,EACnB,cAAc,EACd,YAAY,EACZ,aAAa,EAEb,gBAAgB,EAChB,WAAW,EACX,kBAAkB,EAClB,UAAU,EACV,UAAU,EACX,MAAM,SAAS,CAAC;AAIjB,gGAAgG;AAChG,wBAAgB,gBAAgB,CAAC,KAAK,EAAE;IACtC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,aAAa,CAAC;IACxB,OAAO,EAAE,gBAAgB,CAAC;IAC1B,MAAM,EAAE,gBAAgB,CAAC;CAC1B,GAAG,UAAU,CAOb;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,SAAS,cAAc,EAAE,EACrC,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,OAAO,EAAE,GACrB,gBAAgB,CASlB;AAID,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,UAAU,EAC3D,QAAQ,EAAE,aAAa,EACvB,aAAa,EAAE,mBAAmB,EAClC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,gBAAgB,EACzB,QAAQ,EAAE,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,GACvD,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAWpC;AAID,wBAAsB,yBAAyB,CAAC,OAAO,SAAS,UAAU,EACxE,OAAO,EAAE,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,EAC3C,UAAU,EAAE,SAAS,UAAU,CAAC,OAAO,CAAC,EAAE,EAC1C,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAC7B,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,GAChD,OAAO,CAAC,OAAO,CAAC,CA4BlB;AAID,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,kBAAkB,EAC3B,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,kBAAkB,KAAK,IAAI,GACtD,IAAI,CAQN"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,mBAAmB,EACnB,cAAc,EACd,YAAY,EACZ,aAAa,EAEb,gBAAgB,EAChB,WAAW,EACX,kBAAkB,EAClB,UAAU,EACV,UAAU,EACX,MAAM,SAAS,CAAC;AAIjB,+FAA+F;AAC/F,wBAAgB,gBAAgB,CAAC,KAAK,EAAE;IACtC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,aAAa,CAAC;IACxB,OAAO,EAAE,gBAAgB,CAAC;IAC1B,MAAM,EAAE,gBAAgB,CAAC;CAC1B,GAAG,UAAU,CAOb;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,SAAS,cAAc,EAAE,EACrC,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,OAAO,EAAE,GACrB,gBAAgB,CASlB;AAID,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,UAAU,EAC3D,QAAQ,EAAE,aAAa,EACvB,aAAa,EAAE,mBAAmB,EAClC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,gBAAgB,EACzB,QAAQ,EAAE,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,GACvD,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAWpC;AAID,wBAAsB,yBAAyB,CAAC,OAAO,SAAS,UAAU,EACxE,OAAO,EAAE,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,EAC3C,UAAU,EAAE,SAAS,UAAU,CAAC,OAAO,CAAC,EAAE,EAC1C,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAC7B,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,GAChD,OAAO,CAAC,OAAO,CAAC,CA4BlB;AAID,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,kBAAkB,EAC3B,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,kBAAkB,KAAK,IAAI,GACtD,IAAI,CAQN"}
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","names":[],"sources":["../src/context.ts"],"sourcesContent":["import { error as logError } from './_dev';\nimport { WayfinderApiError } from './errors';\nimport type {\n Middleware,\n NavigationStatus,\n ResolvedQueryParams,\n RouteBranchDef,\n RouteContext,\n RouteLocation,\n RouteMatch,\n RouteMatchBranch,\n RouteParams,\n RouterErrorContext,\n RouteState,\n RouteTable,\n} from './types';\n\n// ─── Route state ──────────────────────────────────────────────────────────────\n\n/** No defensive deep-copy — RouteState is typed readonly; callers always pass fresh objects. */\nexport function createRouteState(input: {\n error?: unknown;\n location: RouteLocation;\n matches: RouteMatchBranch;\n status: NavigationStatus;\n}): RouteState {\n return {\n error: input.error,\n location: input.location,\n matches: input.matches,\n status: input.status,\n };\n}\n\n/**\n * Build a RouteMatchBranch from compiled defs and data results.\n */\nexport function buildMatchBranch(\n branchDefs: readonly RouteBranchDef[],\n params: RouteParams,\n pathname: string,\n dataResults: unknown[],\n): RouteMatchBranch {\n return branchDefs.map(\n (def, i): RouteMatch => ({\n data: dataResults[i],\n name: def.name,\n params: { ...params },\n pathname,\n }),\n );\n}\n\n// ─── Context factory ──────────────────────────────────────────────────────────\n\nexport function createRouteContext<TRoutes extends RouteTable>(\n location: RouteLocation,\n resolvedQuery: ResolvedQueryParams,\n params: RouteParams,\n matches: RouteMatchBranch,\n navigate: RouteContext<RouteParams, TRoutes>['navigate'],\n): RouteContext<RouteParams, TRoutes> {\n return {\n hash: location.hash,\n historyState: location.historyState,\n locals: {},\n matches,\n navigate,\n params,\n pathname: location.pathname,\n query: resolvedQuery,\n };\n}\n\n// ─── Middleware pipeline ──────────────────────────────────────────────────────\n\nexport async function executeMiddlewarePipeline<TRoutes extends RouteTable>(\n context: RouteContext<RouteParams, TRoutes>,\n middleware: readonly Middleware<TRoutes>[],\n terminal: () => Promise<void>,\n wrapMiddlewareError?: (error: unknown) => unknown,\n): Promise<boolean> {\n let terminalRan = false;\n\n async function dispatch(index: number): Promise<void> {\n if (index < middleware.length) {\n let called = false;\n\n try {\n await middleware[index]?.(context, async () => {\n if (called) throw new WayfinderApiError('next() called multiple times');\n\n called = true;\n await dispatch(index + 1);\n });\n } catch (error) {\n throw wrapMiddlewareError?.(error) ?? error;\n }\n\n return;\n }\n\n terminalRan = true;\n await terminal();\n }\n\n await dispatch(0);\n\n return terminalRan;\n}\n\n// ─── Error reporting ──────────────────────────────────────────────────────────\n\nexport function reportError(\n error: unknown,\n context: RouterErrorContext,\n onError?: (e: unknown, ctx: RouterErrorContext) => void,\n): void {\n if (onError) {\n onError(error, context);\n\n return;\n }\n\n logError('Unhandled router error. Provide an onError callback to handle errors explicitly:', error);\n}\n"],"mappings":"kEAoBA,SAAgB,iBAAiB,EAKlB,CACb,MAAO,CACL,MAAO,EAAM,MACb,SAAU,EAAM,SAChB,QAAS,EAAM,QACf,OAAQ,EAAM,MAChB,CACF,CAKA,SAAgB,iBACd,EACA,EACA,EACA,EACkB,CAClB,OAAO,EAAW,KACf,EAAK,KAAmB,CACvB,KAAM,EAAY,GAClB,KAAM,EAAI,KACV,OAAQ,CAAE,GAAG,CAAO,EACpB,UACF,EACF,CACF,CAIA,SAAgB,mBACd,EACA,EACA,EACA,EACA,EACoC,CACpC,MAAO,CACL,KAAM,EAAS,KACf,aAAc,EAAS,aACvB,OAAQ,CAAC,EACT,UACA,WACA,SACA,SAAU,EAAS,SACnB,MAAO,CACT,CACF,CAIA,eAAsB,0BACpB,EACA,EACA,EACA,EACkB,CAClB,IAAI,EAAc,GAElB,eAAe,SAAS,EAA8B,CACpD,GAAI,EAAQ,EAAW,OAAQ,CAC7B,IAAI,EAAS,GAEb,GAAI,CACF,MAAM,EAAW,EAAM,GAAG,EAAS,SAAY,CAC7C,GAAI,EAAQ,MAAM,IAAI,EAAkB,8BAA8B,EAEtE,EAAS,GACT,MAAM,SAAS,EAAQ,CAAC,CAC1B,CAAC,CACH,OAAS,EAAO,CACd,MAAM,IAAsB,CAAK,GAAK,CACxC,CAEA,MACF,CAEA,EAAc,GACd,MAAM,EAAS,CACjB,CAIA,OAFA,MAAM,SAAS,CAAC,EAET,CACT,CAIA,SAAgB,YACd,EACA,EACA,EACM,CACN,GAAI,EAAS,CACX,EAAQ,EAAO,CAAO,EAEtB,MACF,CAGF"}
1
+ {"version":3,"file":"context.js","names":[],"sources":["../src/context.ts"],"sourcesContent":["import { error as logError } from './_dev';\nimport { WayfinderApiError } from './errors';\nimport type {\n Middleware,\n NavigationStatus,\n ResolvedQueryParams,\n RouteBranchDef,\n RouteContext,\n RouteLocation,\n RouteMatch,\n RouteMatchBranch,\n RouteParams,\n RouterErrorContext,\n RouteState,\n RouteTable,\n} from './types';\n\n// ─── Route state ──────────────────────────────────────────────────────────────\n\n/** No defensive deep-copy: RouteState is typed readonly; callers always pass fresh objects. */\nexport function createRouteState(input: {\n error?: unknown;\n location: RouteLocation;\n matches: RouteMatchBranch;\n status: NavigationStatus;\n}): RouteState {\n return {\n error: input.error,\n location: input.location,\n matches: input.matches,\n status: input.status,\n };\n}\n\n/**\n * Build a RouteMatchBranch from compiled defs and data results.\n */\nexport function buildMatchBranch(\n branchDefs: readonly RouteBranchDef[],\n params: RouteParams,\n pathname: string,\n dataResults: unknown[],\n): RouteMatchBranch {\n return branchDefs.map(\n (def, i): RouteMatch => ({\n data: dataResults[i],\n name: def.name,\n params: { ...params },\n pathname,\n }),\n );\n}\n\n// ─── Context factory ──────────────────────────────────────────────────────────\n\nexport function createRouteContext<TRoutes extends RouteTable>(\n location: RouteLocation,\n resolvedQuery: ResolvedQueryParams,\n params: RouteParams,\n matches: RouteMatchBranch,\n navigate: RouteContext<RouteParams, TRoutes>['navigate'],\n): RouteContext<RouteParams, TRoutes> {\n return {\n hash: location.hash,\n historyState: location.historyState,\n locals: {},\n matches,\n navigate,\n params,\n pathname: location.pathname,\n query: resolvedQuery,\n };\n}\n\n// ─── Middleware pipeline ──────────────────────────────────────────────────────\n\nexport async function executeMiddlewarePipeline<TRoutes extends RouteTable>(\n context: RouteContext<RouteParams, TRoutes>,\n middleware: readonly Middleware<TRoutes>[],\n terminal: () => Promise<void>,\n wrapMiddlewareError?: (error: unknown) => unknown,\n): Promise<boolean> {\n let terminalRan = false;\n\n async function dispatch(index: number): Promise<void> {\n if (index < middleware.length) {\n let called = false;\n\n try {\n await middleware[index]?.(context, async () => {\n if (called) throw new WayfinderApiError('next() called multiple times');\n\n called = true;\n await dispatch(index + 1);\n });\n } catch (error) {\n throw wrapMiddlewareError?.(error) ?? error;\n }\n\n return;\n }\n\n terminalRan = true;\n await terminal();\n }\n\n await dispatch(0);\n\n return terminalRan;\n}\n\n// ─── Error reporting ──────────────────────────────────────────────────────────\n\nexport function reportError(\n error: unknown,\n context: RouterErrorContext,\n onError?: (e: unknown, ctx: RouterErrorContext) => void,\n): void {\n if (onError) {\n onError(error, context);\n\n return;\n }\n\n logError('Unhandled router error. Provide an onError callback to handle errors explicitly:', error);\n}\n"],"mappings":"kEAoBA,SAAgB,iBAAiB,EAKlB,CACb,MAAO,CACL,MAAO,EAAM,MACb,SAAU,EAAM,SAChB,QAAS,EAAM,QACf,OAAQ,EAAM,MAChB,CACF,CAKA,SAAgB,iBACd,EACA,EACA,EACA,EACkB,CAClB,OAAO,EAAW,KACf,EAAK,KAAmB,CACvB,KAAM,EAAY,GAClB,KAAM,EAAI,KACV,OAAQ,CAAE,GAAG,CAAO,EACpB,UACF,EACF,CACF,CAIA,SAAgB,mBACd,EACA,EACA,EACA,EACA,EACoC,CACpC,MAAO,CACL,KAAM,EAAS,KACf,aAAc,EAAS,aACvB,OAAQ,CAAC,EACT,UACA,WACA,SACA,SAAU,EAAS,SACnB,MAAO,CACT,CACF,CAIA,eAAsB,0BACpB,EACA,EACA,EACA,EACkB,CAClB,IAAI,EAAc,GAElB,eAAe,SAAS,EAA8B,CACpD,GAAI,EAAQ,EAAW,OAAQ,CAC7B,IAAI,EAAS,GAEb,GAAI,CACF,MAAM,EAAW,EAAM,GAAG,EAAS,SAAY,CAC7C,GAAI,EAAQ,MAAM,IAAI,EAAkB,8BAA8B,EAEtE,EAAS,GACT,MAAM,SAAS,EAAQ,CAAC,CAC1B,CAAC,CACH,OAAS,EAAO,CACd,MAAM,IAAsB,CAAK,GAAK,CACxC,CAEA,MACF,CAEA,EAAc,GACd,MAAM,EAAS,CACjB,CAIA,OAFA,MAAM,SAAS,CAAC,EAET,CACT,CAIA,SAAgB,YACd,EACA,EACA,EACM,CACN,GAAI,EAAS,CACX,EAAQ,EAAO,CAAO,EAEtB,MACF,CAGF"}
@@ -1 +1 @@
1
- {"version":3,"file":"history.cjs","names":[],"sources":["../src/history.ts"],"sourcesContent":["import { joinPaths, normalizePath, stripBase } from './path';\nimport type { HistoryDriver } from './types';\n\n/** Creates a history driver backed by the browser History API. */\nexport function createBrowserHistory(): HistoryDriver {\n return {\n back() {\n window.history.back();\n },\n get location() {\n return {\n hash: window.location.hash,\n pathname: window.location.pathname,\n search: window.location.search,\n // State lives on window.history.state, not window.location.\n state: window.history.state,\n };\n },\n onPopstate(listener) {\n window.addEventListener('popstate', listener);\n\n return () => window.removeEventListener('popstate', listener);\n },\n push(url, state) {\n window.history.pushState(state, '', url);\n },\n replace(url, state) {\n window.history.replaceState(state, '', url);\n },\n };\n}\n\nexport type HashHistoryOptions = { base?: string };\n\nexport function createHashHistory(options: HashHistoryOptions = {}): HistoryDriver {\n const base = normalizePath(options.base ?? '/');\n const baseRoot = base === '/' ? '/' : `${base}/`;\n const location = () => {\n const parsed = new URL(window.location.hash.slice(1) || '/', 'http://localhost');\n\n return {\n hash: parsed.hash,\n pathname: joinPaths(base, parsed.pathname),\n search: parsed.search,\n state: window.history.state,\n };\n };\n const destination = (url: string) => {\n const parsed = new URL(url, 'http://localhost');\n const pathname = stripBase(parsed.pathname, base);\n\n return `${baseRoot}#${pathname}${parsed.search}${parsed.hash}`;\n };\n\n return {\n back() {\n window.history.back();\n },\n /** Anchor-ready form of an internal URL: the hash driver stores routes behind `#`. */\n href: destination,\n get location() {\n return location();\n },\n onPopstate(listener) {\n window.addEventListener('popstate', listener);\n\n return () => window.removeEventListener('popstate', listener);\n },\n push(url, state) {\n window.history.pushState(state, '', destination(url));\n },\n replace(url, state) {\n window.history.replaceState(state, '', destination(url));\n },\n };\n}\n\n/**\n * Picks the history driver for a deployment base path: a site at the origin root\n * (`'/'`) gets real History-API paths; an app deployed under a sub-path\n * (`import.meta.env.BASE_URL` like `'/demos/primal/'`) gets the hash driver, so\n * deep links survive a static host that cannot rewrite the base path to the app.\n */\nexport function createHistoryForBase(base: string): HistoryDriver {\n return base === '/' ? createBrowserHistory() : createHashHistory({ base });\n}\n\ntype MemoryLocation = { hash: string; pathname: string; search: string; state: unknown };\n\n/**\n * Creates an in-memory history driver. Suitable for SSR, tests, and non-browser environments.\n *\n * Semantics mirror the browser History API:\n * - `push()` and `replace()` update the location silently (no listener notification),\n * matching `pushState`/`replaceState` which do not fire `popstate`.\n * - `back()` moves one entry back and notifies subscribers, matching a browser back-button press.\n */\nexport function createMemoryHistory(initialPath = '/'): HistoryDriver {\n const parsed = new URL(initialPath, 'http://localhost');\n const stack: MemoryLocation[] = [\n { hash: parsed.hash, pathname: parsed.pathname, search: parsed.search, state: null },\n ];\n let cursor = 0;\n const listeners = new Set<() => void>();\n\n return {\n back() {\n if (cursor <= 0) return;\n\n cursor -= 1;\n listeners.forEach((l) => {\n l();\n });\n },\n get location(): MemoryLocation {\n return stack[cursor]!;\n },\n onPopstate(listener) {\n listeners.add(listener);\n\n return () => listeners.delete(listener);\n },\n push(url, state = null) {\n const p = new URL(url, 'http://localhost');\n\n stack.splice(cursor + 1);\n stack.push({ hash: p.hash, pathname: p.pathname, search: p.search, state });\n cursor = stack.length - 1;\n // Silent — mirrors browser pushState semantics. Router drives navigation via #handleRoute directly.\n },\n replace(url, state = null) {\n const p = new URL(url, 'http://localhost');\n\n stack[cursor] = { hash: p.hash, pathname: p.pathname, search: p.search, state };\n // Silent — mirrors browser replaceState semantics.\n },\n };\n}\n"],"mappings":"8BAIA,SAAgB,sBAAsC,CACpD,MAAO,CACL,MAAO,CACL,OAAO,QAAQ,KAAK,CACtB,EACA,IAAI,UAAW,CACb,MAAO,CACL,KAAM,OAAO,SAAS,KACtB,SAAU,OAAO,SAAS,SAC1B,OAAQ,OAAO,SAAS,OAExB,MAAO,OAAO,QAAQ,KACxB,CACF,EACA,WAAW,EAAU,CAGnB,OAFA,OAAO,iBAAiB,WAAY,CAAQ,MAE/B,OAAO,oBAAoB,WAAY,CAAQ,CAC9D,EACA,KAAK,EAAK,EAAO,CACf,OAAO,QAAQ,UAAU,EAAO,GAAI,CAAG,CACzC,EACA,QAAQ,EAAK,EAAO,CAClB,OAAO,QAAQ,aAAa,EAAO,GAAI,CAAG,CAC5C,CACF,CACF,CAIA,SAAgB,kBAAkB,EAA8B,CAAC,EAAkB,CACjF,IAAM,EAAO,EAAA,cAAc,EAAQ,MAAQ,GAAG,EACxC,EAAW,IAAS,IAAM,IAAM,GAAG,EAAK,GACxC,aAAiB,CACrB,IAAM,EAAS,IAAI,IAAI,OAAO,SAAS,KAAK,MAAM,CAAC,GAAK,IAAK,kBAAkB,EAE/E,MAAO,CACL,KAAM,EAAO,KACb,SAAU,EAAA,UAAU,EAAM,EAAO,QAAQ,EACzC,OAAQ,EAAO,OACf,MAAO,OAAO,QAAQ,KACxB,CACF,EACM,YAAe,GAAgB,CACnC,IAAM,EAAS,IAAI,IAAI,EAAK,kBAAkB,EACxC,EAAW,EAAA,UAAU,EAAO,SAAU,CAAI,EAEhD,MAAO,GAAG,EAAS,GAAG,IAAW,EAAO,SAAS,EAAO,MAC1D,EAEA,MAAO,CACL,MAAO,CACL,OAAO,QAAQ,KAAK,CACtB,EAEA,KAAM,YACN,IAAI,UAAW,CACb,OAAO,SAAS,CAClB,EACA,WAAW,EAAU,CAGnB,OAFA,OAAO,iBAAiB,WAAY,CAAQ,MAE/B,OAAO,oBAAoB,WAAY,CAAQ,CAC9D,EACA,KAAK,EAAK,EAAO,CACf,OAAO,QAAQ,UAAU,EAAO,GAAI,YAAY,CAAG,CAAC,CACtD,EACA,QAAQ,EAAK,EAAO,CAClB,OAAO,QAAQ,aAAa,EAAO,GAAI,YAAY,CAAG,CAAC,CACzD,CACF,CACF,CAQA,SAAgB,qBAAqB,EAA6B,CAChE,OAAO,IAAS,IAAM,qBAAqB,EAAI,kBAAkB,CAAE,MAAK,CAAC,CAC3E,CAYA,SAAgB,oBAAoB,EAAc,IAAoB,CACpE,IAAM,EAAS,IAAI,IAAI,EAAa,kBAAkB,EAChD,EAA0B,CAC9B,CAAE,KAAM,EAAO,KAAM,SAAU,EAAO,SAAU,OAAQ,EAAO,OAAQ,MAAO,IAAK,CACrF,EACI,EAAS,EACP,EAAY,IAAI,IAEtB,MAAO,CACL,MAAO,CACD,GAAU,IAEd,IACA,EAAU,QAAS,GAAM,CACvB,EAAE,CACJ,CAAC,EACH,EACA,IAAI,UAA2B,CAC7B,OAAO,EAAM,EACf,EACA,WAAW,EAAU,CAGnB,OAFA,EAAU,IAAI,CAAQ,MAET,EAAU,OAAO,CAAQ,CACxC,EACA,KAAK,EAAK,EAAQ,KAAM,CACtB,IAAM,EAAI,IAAI,IAAI,EAAK,kBAAkB,EAEzC,EAAM,OAAO,EAAS,CAAC,EACvB,EAAM,KAAK,CAAE,KAAM,EAAE,KAAM,SAAU,EAAE,SAAU,OAAQ,EAAE,OAAQ,OAAM,CAAC,EAC1E,EAAS,EAAM,OAAS,CAE1B,EACA,QAAQ,EAAK,EAAQ,KAAM,CACzB,IAAM,EAAI,IAAI,IAAI,EAAK,kBAAkB,EAEzC,EAAM,GAAU,CAAE,KAAM,EAAE,KAAM,SAAU,EAAE,SAAU,OAAQ,EAAE,OAAQ,OAAM,CAEhF,CACF,CACF"}
1
+ {"version":3,"file":"history.cjs","names":[],"sources":["../src/history.ts"],"sourcesContent":["import { joinPaths, normalizePath, stripBase } from './path';\nimport type { HistoryDriver } from './types';\n\n/** Creates a history driver backed by the browser History API. */\nexport function createBrowserHistory(): HistoryDriver {\n return {\n back() {\n window.history.back();\n },\n get location() {\n return {\n hash: window.location.hash,\n pathname: window.location.pathname,\n search: window.location.search,\n // State lives on window.history.state, not window.location.\n state: window.history.state,\n };\n },\n onPopstate(listener) {\n window.addEventListener('popstate', listener);\n\n return () => window.removeEventListener('popstate', listener);\n },\n push(url, state) {\n window.history.pushState(state, '', url);\n },\n replace(url, state) {\n window.history.replaceState(state, '', url);\n },\n };\n}\n\nexport type HashHistoryOptions = { base?: string };\n\nexport function createHashHistory(options: HashHistoryOptions = {}): HistoryDriver {\n const base = normalizePath(options.base ?? '/');\n const baseRoot = base === '/' ? '/' : `${base}/`;\n const location = () => {\n const parsed = new URL(window.location.hash.slice(1) || '/', 'http://localhost');\n\n return {\n hash: parsed.hash,\n pathname: joinPaths(base, parsed.pathname),\n search: parsed.search,\n state: window.history.state,\n };\n };\n const destination = (url: string) => {\n const parsed = new URL(url, 'http://localhost');\n const pathname = stripBase(parsed.pathname, base);\n\n return `${baseRoot}#${pathname}${parsed.search}${parsed.hash}`;\n };\n\n return {\n back() {\n window.history.back();\n },\n /** Anchor-ready form of an internal URL: the hash driver stores routes behind `#`. */\n href: destination,\n get location() {\n return location();\n },\n onPopstate(listener) {\n window.addEventListener('popstate', listener);\n\n return () => window.removeEventListener('popstate', listener);\n },\n push(url, state) {\n window.history.pushState(state, '', destination(url));\n },\n replace(url, state) {\n window.history.replaceState(state, '', destination(url));\n },\n };\n}\n\n/**\n * Picks the history driver for a deployment base path: a site at the origin root\n * (`'/'`) gets real History-API paths; an app deployed under a sub-path\n * (`import.meta.env.BASE_URL` like `'/demos/voyage/'`) gets the hash driver, so\n * deep links survive a static host that cannot rewrite the base path to the app.\n */\nexport function createHistoryForBase(base: string): HistoryDriver {\n return base === '/' ? createBrowserHistory() : createHashHistory({ base });\n}\n\ntype MemoryLocation = { hash: string; pathname: string; search: string; state: unknown };\n\n/**\n * Creates an in-memory history driver. Suitable for SSR, tests, and non-browser environments.\n *\n * Semantics mirror the browser History API:\n * - `push()` and `replace()` update the location silently (no listener notification),\n * matching `pushState`/`replaceState` which do not fire `popstate`.\n * - `back()` moves one entry back and notifies subscribers, matching a browser back-button press.\n */\nexport function createMemoryHistory(initialPath = '/'): HistoryDriver {\n const parsed = new URL(initialPath, 'http://localhost');\n const stack: MemoryLocation[] = [\n { hash: parsed.hash, pathname: parsed.pathname, search: parsed.search, state: null },\n ];\n let cursor = 0;\n const listeners = new Set<() => void>();\n\n return {\n back() {\n if (cursor <= 0) return;\n\n cursor -= 1;\n listeners.forEach((l) => {\n l();\n });\n },\n get location(): MemoryLocation {\n return stack[cursor]!;\n },\n onPopstate(listener) {\n listeners.add(listener);\n\n return () => listeners.delete(listener);\n },\n push(url, state = null) {\n const p = new URL(url, 'http://localhost');\n\n stack.splice(cursor + 1);\n stack.push({ hash: p.hash, pathname: p.pathname, search: p.search, state });\n cursor = stack.length - 1;\n // Silent: mirrors browser pushState semantics. Router drives navigation via #handleRoute directly.\n },\n replace(url, state = null) {\n const p = new URL(url, 'http://localhost');\n\n stack[cursor] = { hash: p.hash, pathname: p.pathname, search: p.search, state };\n // Silent: mirrors browser replaceState semantics.\n },\n };\n}\n"],"mappings":"8BAIA,SAAgB,sBAAsC,CACpD,MAAO,CACL,MAAO,CACL,OAAO,QAAQ,KAAK,CACtB,EACA,IAAI,UAAW,CACb,MAAO,CACL,KAAM,OAAO,SAAS,KACtB,SAAU,OAAO,SAAS,SAC1B,OAAQ,OAAO,SAAS,OAExB,MAAO,OAAO,QAAQ,KACxB,CACF,EACA,WAAW,EAAU,CAGnB,OAFA,OAAO,iBAAiB,WAAY,CAAQ,MAE/B,OAAO,oBAAoB,WAAY,CAAQ,CAC9D,EACA,KAAK,EAAK,EAAO,CACf,OAAO,QAAQ,UAAU,EAAO,GAAI,CAAG,CACzC,EACA,QAAQ,EAAK,EAAO,CAClB,OAAO,QAAQ,aAAa,EAAO,GAAI,CAAG,CAC5C,CACF,CACF,CAIA,SAAgB,kBAAkB,EAA8B,CAAC,EAAkB,CACjF,IAAM,EAAO,EAAA,cAAc,EAAQ,MAAQ,GAAG,EACxC,EAAW,IAAS,IAAM,IAAM,GAAG,EAAK,GACxC,aAAiB,CACrB,IAAM,EAAS,IAAI,IAAI,OAAO,SAAS,KAAK,MAAM,CAAC,GAAK,IAAK,kBAAkB,EAE/E,MAAO,CACL,KAAM,EAAO,KACb,SAAU,EAAA,UAAU,EAAM,EAAO,QAAQ,EACzC,OAAQ,EAAO,OACf,MAAO,OAAO,QAAQ,KACxB,CACF,EACM,YAAe,GAAgB,CACnC,IAAM,EAAS,IAAI,IAAI,EAAK,kBAAkB,EACxC,EAAW,EAAA,UAAU,EAAO,SAAU,CAAI,EAEhD,MAAO,GAAG,EAAS,GAAG,IAAW,EAAO,SAAS,EAAO,MAC1D,EAEA,MAAO,CACL,MAAO,CACL,OAAO,QAAQ,KAAK,CACtB,EAEA,KAAM,YACN,IAAI,UAAW,CACb,OAAO,SAAS,CAClB,EACA,WAAW,EAAU,CAGnB,OAFA,OAAO,iBAAiB,WAAY,CAAQ,MAE/B,OAAO,oBAAoB,WAAY,CAAQ,CAC9D,EACA,KAAK,EAAK,EAAO,CACf,OAAO,QAAQ,UAAU,EAAO,GAAI,YAAY,CAAG,CAAC,CACtD,EACA,QAAQ,EAAK,EAAO,CAClB,OAAO,QAAQ,aAAa,EAAO,GAAI,YAAY,CAAG,CAAC,CACzD,CACF,CACF,CAQA,SAAgB,qBAAqB,EAA6B,CAChE,OAAO,IAAS,IAAM,qBAAqB,EAAI,kBAAkB,CAAE,MAAK,CAAC,CAC3E,CAYA,SAAgB,oBAAoB,EAAc,IAAoB,CACpE,IAAM,EAAS,IAAI,IAAI,EAAa,kBAAkB,EAChD,EAA0B,CAC9B,CAAE,KAAM,EAAO,KAAM,SAAU,EAAO,SAAU,OAAQ,EAAO,OAAQ,MAAO,IAAK,CACrF,EACI,EAAS,EACP,EAAY,IAAI,IAEtB,MAAO,CACL,MAAO,CACD,GAAU,IAEd,IACA,EAAU,QAAS,GAAM,CACvB,EAAE,CACJ,CAAC,EACH,EACA,IAAI,UAA2B,CAC7B,OAAO,EAAM,EACf,EACA,WAAW,EAAU,CAGnB,OAFA,EAAU,IAAI,CAAQ,MAET,EAAU,OAAO,CAAQ,CACxC,EACA,KAAK,EAAK,EAAQ,KAAM,CACtB,IAAM,EAAI,IAAI,IAAI,EAAK,kBAAkB,EAEzC,EAAM,OAAO,EAAS,CAAC,EACvB,EAAM,KAAK,CAAE,KAAM,EAAE,KAAM,SAAU,EAAE,SAAU,OAAQ,EAAE,OAAQ,OAAM,CAAC,EAC1E,EAAS,EAAM,OAAS,CAE1B,EACA,QAAQ,EAAK,EAAQ,KAAM,CACzB,IAAM,EAAI,IAAI,IAAI,EAAK,kBAAkB,EAEzC,EAAM,GAAU,CAAE,KAAM,EAAE,KAAM,SAAU,EAAE,SAAU,OAAQ,EAAE,OAAQ,OAAM,CAEhF,CACF,CACF"}
package/dist/history.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare function createHashHistory(options?: HashHistoryOptions): History
8
8
  /**
9
9
  * Picks the history driver for a deployment base path: a site at the origin root
10
10
  * (`'/'`) gets real History-API paths; an app deployed under a sub-path
11
- * (`import.meta.env.BASE_URL` like `'/demos/primal/'`) gets the hash driver, so
11
+ * (`import.meta.env.BASE_URL` like `'/demos/voyage/'`) gets the hash driver, so
12
12
  * deep links survive a static host that cannot rewrite the base path to the app.
13
13
  */
14
14
  export declare function createHistoryForBase(base: string): HistoryDriver;
@@ -1 +1 @@
1
- {"version":3,"file":"history.js","names":[],"sources":["../src/history.ts"],"sourcesContent":["import { joinPaths, normalizePath, stripBase } from './path';\nimport type { HistoryDriver } from './types';\n\n/** Creates a history driver backed by the browser History API. */\nexport function createBrowserHistory(): HistoryDriver {\n return {\n back() {\n window.history.back();\n },\n get location() {\n return {\n hash: window.location.hash,\n pathname: window.location.pathname,\n search: window.location.search,\n // State lives on window.history.state, not window.location.\n state: window.history.state,\n };\n },\n onPopstate(listener) {\n window.addEventListener('popstate', listener);\n\n return () => window.removeEventListener('popstate', listener);\n },\n push(url, state) {\n window.history.pushState(state, '', url);\n },\n replace(url, state) {\n window.history.replaceState(state, '', url);\n },\n };\n}\n\nexport type HashHistoryOptions = { base?: string };\n\nexport function createHashHistory(options: HashHistoryOptions = {}): HistoryDriver {\n const base = normalizePath(options.base ?? '/');\n const baseRoot = base === '/' ? '/' : `${base}/`;\n const location = () => {\n const parsed = new URL(window.location.hash.slice(1) || '/', 'http://localhost');\n\n return {\n hash: parsed.hash,\n pathname: joinPaths(base, parsed.pathname),\n search: parsed.search,\n state: window.history.state,\n };\n };\n const destination = (url: string) => {\n const parsed = new URL(url, 'http://localhost');\n const pathname = stripBase(parsed.pathname, base);\n\n return `${baseRoot}#${pathname}${parsed.search}${parsed.hash}`;\n };\n\n return {\n back() {\n window.history.back();\n },\n /** Anchor-ready form of an internal URL: the hash driver stores routes behind `#`. */\n href: destination,\n get location() {\n return location();\n },\n onPopstate(listener) {\n window.addEventListener('popstate', listener);\n\n return () => window.removeEventListener('popstate', listener);\n },\n push(url, state) {\n window.history.pushState(state, '', destination(url));\n },\n replace(url, state) {\n window.history.replaceState(state, '', destination(url));\n },\n };\n}\n\n/**\n * Picks the history driver for a deployment base path: a site at the origin root\n * (`'/'`) gets real History-API paths; an app deployed under a sub-path\n * (`import.meta.env.BASE_URL` like `'/demos/primal/'`) gets the hash driver, so\n * deep links survive a static host that cannot rewrite the base path to the app.\n */\nexport function createHistoryForBase(base: string): HistoryDriver {\n return base === '/' ? createBrowserHistory() : createHashHistory({ base });\n}\n\ntype MemoryLocation = { hash: string; pathname: string; search: string; state: unknown };\n\n/**\n * Creates an in-memory history driver. Suitable for SSR, tests, and non-browser environments.\n *\n * Semantics mirror the browser History API:\n * - `push()` and `replace()` update the location silently (no listener notification),\n * matching `pushState`/`replaceState` which do not fire `popstate`.\n * - `back()` moves one entry back and notifies subscribers, matching a browser back-button press.\n */\nexport function createMemoryHistory(initialPath = '/'): HistoryDriver {\n const parsed = new URL(initialPath, 'http://localhost');\n const stack: MemoryLocation[] = [\n { hash: parsed.hash, pathname: parsed.pathname, search: parsed.search, state: null },\n ];\n let cursor = 0;\n const listeners = new Set<() => void>();\n\n return {\n back() {\n if (cursor <= 0) return;\n\n cursor -= 1;\n listeners.forEach((l) => {\n l();\n });\n },\n get location(): MemoryLocation {\n return stack[cursor]!;\n },\n onPopstate(listener) {\n listeners.add(listener);\n\n return () => listeners.delete(listener);\n },\n push(url, state = null) {\n const p = new URL(url, 'http://localhost');\n\n stack.splice(cursor + 1);\n stack.push({ hash: p.hash, pathname: p.pathname, search: p.search, state });\n cursor = stack.length - 1;\n // Silent — mirrors browser pushState semantics. Router drives navigation via #handleRoute directly.\n },\n replace(url, state = null) {\n const p = new URL(url, 'http://localhost');\n\n stack[cursor] = { hash: p.hash, pathname: p.pathname, search: p.search, state };\n // Silent — mirrors browser replaceState semantics.\n },\n };\n}\n"],"mappings":"wEAIA,SAAgB,sBAAsC,CACpD,MAAO,CACL,MAAO,CACL,OAAO,QAAQ,KAAK,CACtB,EACA,IAAI,UAAW,CACb,MAAO,CACL,KAAM,OAAO,SAAS,KACtB,SAAU,OAAO,SAAS,SAC1B,OAAQ,OAAO,SAAS,OAExB,MAAO,OAAO,QAAQ,KACxB,CACF,EACA,WAAW,EAAU,CAGnB,OAFA,OAAO,iBAAiB,WAAY,CAAQ,MAE/B,OAAO,oBAAoB,WAAY,CAAQ,CAC9D,EACA,KAAK,EAAK,EAAO,CACf,OAAO,QAAQ,UAAU,EAAO,GAAI,CAAG,CACzC,EACA,QAAQ,EAAK,EAAO,CAClB,OAAO,QAAQ,aAAa,EAAO,GAAI,CAAG,CAC5C,CACF,CACF,CAIA,SAAgB,kBAAkB,EAA8B,CAAC,EAAkB,CACjF,IAAM,EAAO,EAAc,EAAQ,MAAQ,GAAG,EACxC,EAAW,IAAS,IAAM,IAAM,GAAG,EAAK,GACxC,aAAiB,CACrB,IAAM,EAAS,IAAI,IAAI,OAAO,SAAS,KAAK,MAAM,CAAC,GAAK,IAAK,kBAAkB,EAE/E,MAAO,CACL,KAAM,EAAO,KACb,SAAU,EAAU,EAAM,EAAO,QAAQ,EACzC,OAAQ,EAAO,OACf,MAAO,OAAO,QAAQ,KACxB,CACF,EACM,YAAe,GAAgB,CACnC,IAAM,EAAS,IAAI,IAAI,EAAK,kBAAkB,EACxC,EAAW,EAAU,EAAO,SAAU,CAAI,EAEhD,MAAO,GAAG,EAAS,GAAG,IAAW,EAAO,SAAS,EAAO,MAC1D,EAEA,MAAO,CACL,MAAO,CACL,OAAO,QAAQ,KAAK,CACtB,EAEA,KAAM,YACN,IAAI,UAAW,CACb,OAAO,SAAS,CAClB,EACA,WAAW,EAAU,CAGnB,OAFA,OAAO,iBAAiB,WAAY,CAAQ,MAE/B,OAAO,oBAAoB,WAAY,CAAQ,CAC9D,EACA,KAAK,EAAK,EAAO,CACf,OAAO,QAAQ,UAAU,EAAO,GAAI,YAAY,CAAG,CAAC,CACtD,EACA,QAAQ,EAAK,EAAO,CAClB,OAAO,QAAQ,aAAa,EAAO,GAAI,YAAY,CAAG,CAAC,CACzD,CACF,CACF,CAQA,SAAgB,qBAAqB,EAA6B,CAChE,OAAO,IAAS,IAAM,qBAAqB,EAAI,kBAAkB,CAAE,MAAK,CAAC,CAC3E,CAYA,SAAgB,oBAAoB,EAAc,IAAoB,CACpE,IAAM,EAAS,IAAI,IAAI,EAAa,kBAAkB,EAChD,EAA0B,CAC9B,CAAE,KAAM,EAAO,KAAM,SAAU,EAAO,SAAU,OAAQ,EAAO,OAAQ,MAAO,IAAK,CACrF,EACI,EAAS,EACP,EAAY,IAAI,IAEtB,MAAO,CACL,MAAO,CACD,GAAU,IAEd,IACA,EAAU,QAAS,GAAM,CACvB,EAAE,CACJ,CAAC,EACH,EACA,IAAI,UAA2B,CAC7B,OAAO,EAAM,EACf,EACA,WAAW,EAAU,CAGnB,OAFA,EAAU,IAAI,CAAQ,MAET,EAAU,OAAO,CAAQ,CACxC,EACA,KAAK,EAAK,EAAQ,KAAM,CACtB,IAAM,EAAI,IAAI,IAAI,EAAK,kBAAkB,EAEzC,EAAM,OAAO,EAAS,CAAC,EACvB,EAAM,KAAK,CAAE,KAAM,EAAE,KAAM,SAAU,EAAE,SAAU,OAAQ,EAAE,OAAQ,OAAM,CAAC,EAC1E,EAAS,EAAM,OAAS,CAE1B,EACA,QAAQ,EAAK,EAAQ,KAAM,CACzB,IAAM,EAAI,IAAI,IAAI,EAAK,kBAAkB,EAEzC,EAAM,GAAU,CAAE,KAAM,EAAE,KAAM,SAAU,EAAE,SAAU,OAAQ,EAAE,OAAQ,OAAM,CAEhF,CACF,CACF"}
1
+ {"version":3,"file":"history.js","names":[],"sources":["../src/history.ts"],"sourcesContent":["import { joinPaths, normalizePath, stripBase } from './path';\nimport type { HistoryDriver } from './types';\n\n/** Creates a history driver backed by the browser History API. */\nexport function createBrowserHistory(): HistoryDriver {\n return {\n back() {\n window.history.back();\n },\n get location() {\n return {\n hash: window.location.hash,\n pathname: window.location.pathname,\n search: window.location.search,\n // State lives on window.history.state, not window.location.\n state: window.history.state,\n };\n },\n onPopstate(listener) {\n window.addEventListener('popstate', listener);\n\n return () => window.removeEventListener('popstate', listener);\n },\n push(url, state) {\n window.history.pushState(state, '', url);\n },\n replace(url, state) {\n window.history.replaceState(state, '', url);\n },\n };\n}\n\nexport type HashHistoryOptions = { base?: string };\n\nexport function createHashHistory(options: HashHistoryOptions = {}): HistoryDriver {\n const base = normalizePath(options.base ?? '/');\n const baseRoot = base === '/' ? '/' : `${base}/`;\n const location = () => {\n const parsed = new URL(window.location.hash.slice(1) || '/', 'http://localhost');\n\n return {\n hash: parsed.hash,\n pathname: joinPaths(base, parsed.pathname),\n search: parsed.search,\n state: window.history.state,\n };\n };\n const destination = (url: string) => {\n const parsed = new URL(url, 'http://localhost');\n const pathname = stripBase(parsed.pathname, base);\n\n return `${baseRoot}#${pathname}${parsed.search}${parsed.hash}`;\n };\n\n return {\n back() {\n window.history.back();\n },\n /** Anchor-ready form of an internal URL: the hash driver stores routes behind `#`. */\n href: destination,\n get location() {\n return location();\n },\n onPopstate(listener) {\n window.addEventListener('popstate', listener);\n\n return () => window.removeEventListener('popstate', listener);\n },\n push(url, state) {\n window.history.pushState(state, '', destination(url));\n },\n replace(url, state) {\n window.history.replaceState(state, '', destination(url));\n },\n };\n}\n\n/**\n * Picks the history driver for a deployment base path: a site at the origin root\n * (`'/'`) gets real History-API paths; an app deployed under a sub-path\n * (`import.meta.env.BASE_URL` like `'/demos/voyage/'`) gets the hash driver, so\n * deep links survive a static host that cannot rewrite the base path to the app.\n */\nexport function createHistoryForBase(base: string): HistoryDriver {\n return base === '/' ? createBrowserHistory() : createHashHistory({ base });\n}\n\ntype MemoryLocation = { hash: string; pathname: string; search: string; state: unknown };\n\n/**\n * Creates an in-memory history driver. Suitable for SSR, tests, and non-browser environments.\n *\n * Semantics mirror the browser History API:\n * - `push()` and `replace()` update the location silently (no listener notification),\n * matching `pushState`/`replaceState` which do not fire `popstate`.\n * - `back()` moves one entry back and notifies subscribers, matching a browser back-button press.\n */\nexport function createMemoryHistory(initialPath = '/'): HistoryDriver {\n const parsed = new URL(initialPath, 'http://localhost');\n const stack: MemoryLocation[] = [\n { hash: parsed.hash, pathname: parsed.pathname, search: parsed.search, state: null },\n ];\n let cursor = 0;\n const listeners = new Set<() => void>();\n\n return {\n back() {\n if (cursor <= 0) return;\n\n cursor -= 1;\n listeners.forEach((l) => {\n l();\n });\n },\n get location(): MemoryLocation {\n return stack[cursor]!;\n },\n onPopstate(listener) {\n listeners.add(listener);\n\n return () => listeners.delete(listener);\n },\n push(url, state = null) {\n const p = new URL(url, 'http://localhost');\n\n stack.splice(cursor + 1);\n stack.push({ hash: p.hash, pathname: p.pathname, search: p.search, state });\n cursor = stack.length - 1;\n // Silent: mirrors browser pushState semantics. Router drives navigation via #handleRoute directly.\n },\n replace(url, state = null) {\n const p = new URL(url, 'http://localhost');\n\n stack[cursor] = { hash: p.hash, pathname: p.pathname, search: p.search, state };\n // Silent: mirrors browser replaceState semantics.\n },\n };\n}\n"],"mappings":"wEAIA,SAAgB,sBAAsC,CACpD,MAAO,CACL,MAAO,CACL,OAAO,QAAQ,KAAK,CACtB,EACA,IAAI,UAAW,CACb,MAAO,CACL,KAAM,OAAO,SAAS,KACtB,SAAU,OAAO,SAAS,SAC1B,OAAQ,OAAO,SAAS,OAExB,MAAO,OAAO,QAAQ,KACxB,CACF,EACA,WAAW,EAAU,CAGnB,OAFA,OAAO,iBAAiB,WAAY,CAAQ,MAE/B,OAAO,oBAAoB,WAAY,CAAQ,CAC9D,EACA,KAAK,EAAK,EAAO,CACf,OAAO,QAAQ,UAAU,EAAO,GAAI,CAAG,CACzC,EACA,QAAQ,EAAK,EAAO,CAClB,OAAO,QAAQ,aAAa,EAAO,GAAI,CAAG,CAC5C,CACF,CACF,CAIA,SAAgB,kBAAkB,EAA8B,CAAC,EAAkB,CACjF,IAAM,EAAO,EAAc,EAAQ,MAAQ,GAAG,EACxC,EAAW,IAAS,IAAM,IAAM,GAAG,EAAK,GACxC,aAAiB,CACrB,IAAM,EAAS,IAAI,IAAI,OAAO,SAAS,KAAK,MAAM,CAAC,GAAK,IAAK,kBAAkB,EAE/E,MAAO,CACL,KAAM,EAAO,KACb,SAAU,EAAU,EAAM,EAAO,QAAQ,EACzC,OAAQ,EAAO,OACf,MAAO,OAAO,QAAQ,KACxB,CACF,EACM,YAAe,GAAgB,CACnC,IAAM,EAAS,IAAI,IAAI,EAAK,kBAAkB,EACxC,EAAW,EAAU,EAAO,SAAU,CAAI,EAEhD,MAAO,GAAG,EAAS,GAAG,IAAW,EAAO,SAAS,EAAO,MAC1D,EAEA,MAAO,CACL,MAAO,CACL,OAAO,QAAQ,KAAK,CACtB,EAEA,KAAM,YACN,IAAI,UAAW,CACb,OAAO,SAAS,CAClB,EACA,WAAW,EAAU,CAGnB,OAFA,OAAO,iBAAiB,WAAY,CAAQ,MAE/B,OAAO,oBAAoB,WAAY,CAAQ,CAC9D,EACA,KAAK,EAAK,EAAO,CACf,OAAO,QAAQ,UAAU,EAAO,GAAI,YAAY,CAAG,CAAC,CACtD,EACA,QAAQ,EAAK,EAAO,CAClB,OAAO,QAAQ,aAAa,EAAO,GAAI,YAAY,CAAG,CAAC,CACzD,CACF,CACF,CAQA,SAAgB,qBAAqB,EAA6B,CAChE,OAAO,IAAS,IAAM,qBAAqB,EAAI,kBAAkB,CAAE,MAAK,CAAC,CAC3E,CAYA,SAAgB,oBAAoB,EAAc,IAAoB,CACpE,IAAM,EAAS,IAAI,IAAI,EAAa,kBAAkB,EAChD,EAA0B,CAC9B,CAAE,KAAM,EAAO,KAAM,SAAU,EAAO,SAAU,OAAQ,EAAO,OAAQ,MAAO,IAAK,CACrF,EACI,EAAS,EACP,EAAY,IAAI,IAEtB,MAAO,CACL,MAAO,CACD,GAAU,IAEd,IACA,EAAU,QAAS,GAAM,CACvB,EAAE,CACJ,CAAC,EACH,EACA,IAAI,UAA2B,CAC7B,OAAO,EAAM,EACf,EACA,WAAW,EAAU,CAGnB,OAFA,EAAU,IAAI,CAAQ,MAET,EAAU,OAAO,CAAQ,CACxC,EACA,KAAK,EAAK,EAAQ,KAAM,CACtB,IAAM,EAAI,IAAI,IAAI,EAAK,kBAAkB,EAEzC,EAAM,OAAO,EAAS,CAAC,EACvB,EAAM,KAAK,CAAE,KAAM,EAAE,KAAM,SAAU,EAAE,SAAU,OAAQ,EAAE,OAAQ,OAAM,CAAC,EAC1E,EAAS,EAAM,OAAS,CAE1B,EACA,QAAQ,EAAK,EAAQ,KAAM,CACzB,IAAM,EAAI,IAAI,IAAI,EAAK,kBAAkB,EAEzC,EAAM,GAAU,CAAE,KAAM,EAAE,KAAM,SAAU,EAAE,SAAU,OAAQ,EAAE,OAAQ,OAAM,CAEhF,CACF,CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.cjs","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["import type { Middleware, NamedNavigationTarget, NavigateOptions, RawNavigationTarget, RouteTable } from './types';\n\n/**\n * Middleware helper that redirects navigation to another route.\n * Attach to a route's `middleware` array for declarative redirect patterns.\n *\n * Note: `next()` is intentionally not called — this middleware blocks the chain\n * and immediately navigates to the target.\n *\n * @example\n * // Redirect /old → /new\n * routes: {\n * old: { middleware: [redirectTo({ path: '/new' })], path: '/old' },\n * new: { path: '/new' },\n * }\n */\nexport function redirectTo<TRoutes extends RouteTable = RouteTable>(\n target: NamedNavigationTarget<TRoutes> | RawNavigationTarget | string,\n options?: NavigateOptions,\n): Middleware<TRoutes> {\n return async (context) => {\n await context.navigate(target, options);\n };\n}\n"],"mappings":"AAgBA,SAAgB,WACd,EACA,EACqB,CACrB,OAAO,KAAO,IAAY,CACxB,MAAM,EAAQ,SAAS,EAAQ,CAAO,CACxC,CACF"}
1
+ {"version":3,"file":"middleware.cjs","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["import type { Middleware, NamedNavigationTarget, NavigateOptions, RawNavigationTarget, RouteTable } from './types';\n\n/**\n * Middleware helper that redirects navigation to another route.\n * Attach to a route's `middleware` array for declarative redirect patterns.\n *\n * Note: `next()` is intentionally not called: this middleware blocks the chain\n * and immediately navigates to the target.\n *\n * @example\n * // Redirect /old → /new\n * routes: {\n * old: { middleware: [redirectTo({ path: '/new' })], path: '/old' },\n * new: { path: '/new' },\n * }\n */\nexport function redirectTo<TRoutes extends RouteTable = RouteTable>(\n target: NamedNavigationTarget<TRoutes> | RawNavigationTarget | string,\n options?: NavigateOptions,\n): Middleware<TRoutes> {\n return async (context) => {\n await context.navigate(target, options);\n };\n}\n"],"mappings":"AAgBA,SAAgB,WACd,EACA,EACqB,CACrB,OAAO,KAAO,IAAY,CACxB,MAAM,EAAQ,SAAS,EAAQ,CAAO,CACxC,CACF"}
@@ -3,7 +3,7 @@ import type { Middleware, NamedNavigationTarget, NavigateOptions, RawNavigationT
3
3
  * Middleware helper that redirects navigation to another route.
4
4
  * Attach to a route's `middleware` array for declarative redirect patterns.
5
5
  *
6
- * Note: `next()` is intentionally not called — this middleware blocks the chain
6
+ * Note: `next()` is intentionally not called: this middleware blocks the chain
7
7
  * and immediately navigates to the target.
8
8
  *
9
9
  * @example
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.js","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["import type { Middleware, NamedNavigationTarget, NavigateOptions, RawNavigationTarget, RouteTable } from './types';\n\n/**\n * Middleware helper that redirects navigation to another route.\n * Attach to a route's `middleware` array for declarative redirect patterns.\n *\n * Note: `next()` is intentionally not called — this middleware blocks the chain\n * and immediately navigates to the target.\n *\n * @example\n * // Redirect /old → /new\n * routes: {\n * old: { middleware: [redirectTo({ path: '/new' })], path: '/old' },\n * new: { path: '/new' },\n * }\n */\nexport function redirectTo<TRoutes extends RouteTable = RouteTable>(\n target: NamedNavigationTarget<TRoutes> | RawNavigationTarget | string,\n options?: NavigateOptions,\n): Middleware<TRoutes> {\n return async (context) => {\n await context.navigate(target, options);\n };\n}\n"],"mappings":"AAgBA,SAAgB,WACd,EACA,EACqB,CACrB,OAAO,KAAO,IAAY,CACxB,MAAM,EAAQ,SAAS,EAAQ,CAAO,CACxC,CACF"}
1
+ {"version":3,"file":"middleware.js","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["import type { Middleware, NamedNavigationTarget, NavigateOptions, RawNavigationTarget, RouteTable } from './types';\n\n/**\n * Middleware helper that redirects navigation to another route.\n * Attach to a route's `middleware` array for declarative redirect patterns.\n *\n * Note: `next()` is intentionally not called: this middleware blocks the chain\n * and immediately navigates to the target.\n *\n * @example\n * // Redirect /old → /new\n * routes: {\n * old: { middleware: [redirectTo({ path: '/new' })], path: '/old' },\n * new: { path: '/new' },\n * }\n */\nexport function redirectTo<TRoutes extends RouteTable = RouteTable>(\n target: NamedNavigationTarget<TRoutes> | RawNavigationTarget | string,\n options?: NavigateOptions,\n): Middleware<TRoutes> {\n return async (context) => {\n await context.navigate(target, options);\n };\n}\n"],"mappings":"AAgBA,SAAgB,WACd,EACA,EACqB,CACrB,OAAO,KAAO,IAAY,CACxB,MAAM,EAAQ,SAAS,EAAQ,CAAO,CACxC,CACF"}
package/dist/path.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"path.cjs","names":[],"sources":["../src/path.ts"],"sourcesContent":["import { WayfinderConfigError, WayfinderRouteError } from './errors';\nimport type { QueryParams, ResolvedQueryParams, RouteLocation, RouteMatcher, RouteParams, RouteRecord } from './types';\n\n/** Ensure leading slash, collapse duplicate slashes, preserve root. */\nexport function normalizePath(path: string): string {\n const normalized = `/${path}`.replace(/\\/+/g, '/');\n\n return normalized !== '/' && normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;\n}\n\nexport function joinPaths(base: string, path: string): string {\n return normalizePath(`${base}/${path}`);\n}\n\nfunction escapeRegex(input: string): string {\n return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nexport function compilePathMatcher(path: string): RouteMatcher {\n const normalized = normalizePath(path);\n\n if (normalized === '/') {\n return {\n paramNames: [],\n pattern: /^\\/$/,\n prefixPattern: /^\\/$/,\n };\n }\n\n const paramNames: string[] = [];\n const segments = normalized.slice(1).split('/');\n const regexParts: string[] = ['^'];\n const prefixParts: string[] = ['^'];\n\n for (let i = 0; i < segments.length; i += 1) {\n const segment = segments[i]!;\n\n if (segment === '*') {\n if (i !== segments.length - 1) {\n throw new WayfinderConfigError(`Wildcard \"*\" must be the final segment in path: ${path}`);\n }\n\n regexParts.push('(?:/.*)?');\n break;\n }\n\n if (segment.startsWith(':') && segment.endsWith('*')) {\n if (i !== segments.length - 1) {\n throw new WayfinderConfigError(`Wildcard param must be final segment in path: ${path}`);\n }\n\n const name = segment.slice(1, -1);\n\n if (!/^\\w+$/.test(name)) {\n throw new WayfinderConfigError(\n `Invalid param name \":${name}\" in path \"${path}\". Param names must only contain word characters (a–z, A–Z, 0–9, _).`,\n );\n }\n\n paramNames.push(name);\n regexParts.push(i === segments.length - 1 ? '(?:/(.*))?' : '/(.*)');\n prefixParts.push('/.*');\n break;\n }\n\n if (segment.startsWith(':')) {\n const name = segment.slice(1);\n\n if (!/^\\w+$/.test(name)) {\n throw new WayfinderConfigError(\n `Invalid param name \":${name}\" in path \"${path}\". Param names must only contain word characters (a–z, A–Z, 0–9, _).`,\n );\n }\n\n paramNames.push(name);\n regexParts.push('/([^/]+)');\n prefixParts.push('/[^/]+');\n continue;\n }\n\n const escaped = escapeRegex(segment);\n\n regexParts.push(`/${escaped}`);\n prefixParts.push(`/${escaped}`);\n }\n\n regexParts.push('$');\n prefixParts.push('(?:/.*)?$');\n\n return {\n paramNames,\n pattern: new RegExp(regexParts.join('')),\n prefixPattern: new RegExp(prefixParts.join('')),\n };\n}\n\nexport function matchRecord(pathname: string, record: RouteRecord): RouteParams | null {\n const match = record.matcher.pattern.exec(pathname);\n\n if (!match) return null;\n\n const params: RouteParams = {};\n\n record.matcher.paramNames.forEach((name: string, index: number) => {\n const value = match[index + 1] ?? '';\n\n try {\n params[name] = decodeURIComponent(value);\n } catch {\n params[name] = value;\n }\n });\n\n return params;\n}\n\nexport function matchRouteFor(\n pathname: string,\n records: readonly RouteRecord[],\n): { params: RouteParams; record?: RouteRecord } {\n for (const record of records) {\n const params = matchRecord(pathname, record);\n\n if (params) return { params, record };\n }\n\n return { params: {} };\n}\n\nexport function matchesPrefix(pathname: string, record: RouteRecord): boolean {\n return record.matcher.prefixPattern.test(pathname);\n}\n\n/**\n * Parse `?foo=a&foo=b&bar=c` into `{ foo: ['a', 'b'], bar: 'c' }`.\n *\n * @security Query keys come directly from the (attacker-controlled) URL. Plain bracket\n * assignment (`out[key] = value`) would let a key like `__proto__` reach the inherited\n * `Object.prototype.__proto__` setter and silently rewrite `out`'s own prototype instead of\n * storing a value — `setQueryValue` uses `Object.defineProperty` so every key, including\n * `__proto__`/`constructor`/`prototype`, is always stored as a plain own data property.\n */\nexport function parseQuery(queryString: string): QueryParams {\n const search = new URLSearchParams(queryString);\n const out: QueryParams = {};\n\n const setQueryValue = (key: string, value: QueryParams[string]): void => {\n Object.defineProperty(out, key, { configurable: true, enumerable: true, value, writable: true });\n };\n\n for (const [key, value] of search.entries()) {\n const existing = Object.hasOwn(out, key) ? out[key] : undefined;\n\n if (existing === undefined) {\n setQueryValue(key, value);\n continue;\n }\n\n if (Array.isArray(existing)) {\n existing.push(value);\n continue;\n }\n\n setQueryValue(key, [existing, value]);\n }\n\n return out;\n}\n\n/** Build a URL from a path pattern, params, and query, respecting the router base. */\nexport function buildUrl(base: string, pattern: string, params: RouteParams = {}, query?: ResolvedQueryParams): string {\n let path = pattern.replace(/:(\\w+)(\\*)?/g, (_match, key: string, isWildcard: string) => {\n const value = params[key];\n\n if (value == null) throw new WayfinderRouteError(`Missing path param: ${key}`);\n\n if (isWildcard) {\n return value\n .split('/')\n .map((segment) => encodeURIComponent(segment))\n .join('/');\n }\n\n return encodeURIComponent(value);\n });\n\n if (query && Object.keys(query).length) {\n const search = new URLSearchParams();\n\n Object.entries(query).forEach(([key, value]) => {\n if (Array.isArray(value))\n value.forEach((item) => {\n search.append(key, String(item));\n });\n else search.set(key, String(value));\n });\n\n path += `?${search.toString()}`;\n }\n\n return joinPaths(base, path);\n}\n\n// ─── Location helpers (formerly in resolve.ts) ────────────────────────────────\n\nexport function stripBase(pathname: string, base = '/'): string {\n const normalizedPath = normalizePath(pathname);\n const normalizedBase = normalizePath(base);\n\n if (normalizedBase === '/') return normalizedPath;\n\n if (normalizedPath === normalizedBase) return '/';\n\n const prefix = `${normalizedBase}/`;\n\n return normalizedPath.startsWith(prefix)\n ? normalizePath(normalizedPath.slice(normalizedBase.length))\n : normalizedPath;\n}\n\nexport function readLocation(\n base: string,\n history: { location: { hash: string; pathname: string; search: string; state: unknown } },\n): RouteLocation {\n return {\n hash: history.location.hash.replace(/^#/, ''),\n historyState: history.location.state,\n pathname: stripBase(history.location.pathname || '/', base),\n query: parseQuery(history.location.search || ''),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"path.cjs","names":[],"sources":["../src/path.ts"],"sourcesContent":["import { WayfinderConfigError, WayfinderRouteError } from './errors';\nimport type { QueryParams, ResolvedQueryParams, RouteLocation, RouteMatcher, RouteParams, RouteRecord } from './types';\n\n/** Ensure leading slash, collapse duplicate slashes, preserve root. */\nexport function normalizePath(path: string): string {\n const normalized = `/${path}`.replace(/\\/+/g, '/');\n\n return normalized !== '/' && normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;\n}\n\nexport function joinPaths(base: string, path: string): string {\n return normalizePath(`${base}/${path}`);\n}\n\nfunction escapeRegex(input: string): string {\n return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nexport function compilePathMatcher(path: string): RouteMatcher {\n const normalized = normalizePath(path);\n\n if (normalized === '/') {\n return {\n paramNames: [],\n pattern: /^\\/$/,\n prefixPattern: /^\\/$/,\n };\n }\n\n const paramNames: string[] = [];\n const segments = normalized.slice(1).split('/');\n const regexParts: string[] = ['^'];\n const prefixParts: string[] = ['^'];\n\n for (let i = 0; i < segments.length; i += 1) {\n const segment = segments[i]!;\n\n if (segment === '*') {\n if (i !== segments.length - 1) {\n throw new WayfinderConfigError(`Wildcard \"*\" must be the final segment in path: ${path}`);\n }\n\n regexParts.push('(?:/.*)?');\n break;\n }\n\n if (segment.startsWith(':') && segment.endsWith('*')) {\n if (i !== segments.length - 1) {\n throw new WayfinderConfigError(`Wildcard param must be final segment in path: ${path}`);\n }\n\n const name = segment.slice(1, -1);\n\n if (!/^\\w+$/.test(name)) {\n throw new WayfinderConfigError(\n `Invalid param name \":${name}\" in path \"${path}\". Param names must only contain word characters (a–z, A–Z, 0–9, _).`,\n );\n }\n\n paramNames.push(name);\n regexParts.push(i === segments.length - 1 ? '(?:/(.*))?' : '/(.*)');\n prefixParts.push('/.*');\n break;\n }\n\n if (segment.startsWith(':')) {\n const name = segment.slice(1);\n\n if (!/^\\w+$/.test(name)) {\n throw new WayfinderConfigError(\n `Invalid param name \":${name}\" in path \"${path}\". Param names must only contain word characters (a–z, A–Z, 0–9, _).`,\n );\n }\n\n paramNames.push(name);\n regexParts.push('/([^/]+)');\n prefixParts.push('/[^/]+');\n continue;\n }\n\n const escaped = escapeRegex(segment);\n\n regexParts.push(`/${escaped}`);\n prefixParts.push(`/${escaped}`);\n }\n\n regexParts.push('$');\n prefixParts.push('(?:/.*)?$');\n\n return {\n paramNames,\n pattern: new RegExp(regexParts.join('')),\n prefixPattern: new RegExp(prefixParts.join('')),\n };\n}\n\nexport function matchRecord(pathname: string, record: RouteRecord): RouteParams | null {\n const match = record.matcher.pattern.exec(pathname);\n\n if (!match) return null;\n\n const params: RouteParams = {};\n\n record.matcher.paramNames.forEach((name: string, index: number) => {\n const value = match[index + 1] ?? '';\n\n try {\n params[name] = decodeURIComponent(value);\n } catch {\n params[name] = value;\n }\n });\n\n return params;\n}\n\nexport function matchRouteFor(\n pathname: string,\n records: readonly RouteRecord[],\n): { params: RouteParams; record?: RouteRecord } {\n for (const record of records) {\n const params = matchRecord(pathname, record);\n\n if (params) return { params, record };\n }\n\n return { params: {} };\n}\n\nexport function matchesPrefix(pathname: string, record: RouteRecord): boolean {\n return record.matcher.prefixPattern.test(pathname);\n}\n\n/**\n * Parse `?foo=a&foo=b&bar=c` into `{ foo: ['a', 'b'], bar: 'c' }`.\n *\n * @security Query keys come directly from the (attacker-controlled) URL. Plain bracket\n * assignment (`out[key] = value`) would let a key like `__proto__` reach the inherited\n * `Object.prototype.__proto__` setter and silently rewrite `out`'s own prototype instead of\n * storing a value: `setQueryValue` uses `Object.defineProperty` so every key, including\n * `__proto__`/`constructor`/`prototype`, is always stored as a plain own data property.\n */\nexport function parseQuery(queryString: string): QueryParams {\n const search = new URLSearchParams(queryString);\n const out: QueryParams = {};\n\n const setQueryValue = (key: string, value: QueryParams[string]): void => {\n Object.defineProperty(out, key, { configurable: true, enumerable: true, value, writable: true });\n };\n\n for (const [key, value] of search.entries()) {\n const existing = Object.hasOwn(out, key) ? out[key] : undefined;\n\n if (existing === undefined) {\n setQueryValue(key, value);\n continue;\n }\n\n if (Array.isArray(existing)) {\n existing.push(value);\n continue;\n }\n\n setQueryValue(key, [existing, value]);\n }\n\n return out;\n}\n\n/** Build a URL from a path pattern, params, and query, respecting the router base. */\nexport function buildUrl(base: string, pattern: string, params: RouteParams = {}, query?: ResolvedQueryParams): string {\n let path = pattern.replace(/:(\\w+)(\\*)?/g, (_match, key: string, isWildcard: string) => {\n const value = params[key];\n\n if (value == null) throw new WayfinderRouteError(`Missing path param: ${key}`);\n\n if (isWildcard) {\n return value\n .split('/')\n .map((segment) => encodeURIComponent(segment))\n .join('/');\n }\n\n return encodeURIComponent(value);\n });\n\n if (query && Object.keys(query).length) {\n const search = new URLSearchParams();\n\n Object.entries(query).forEach(([key, value]) => {\n if (Array.isArray(value))\n value.forEach((item) => {\n search.append(key, String(item));\n });\n else search.set(key, String(value));\n });\n\n path += `?${search.toString()}`;\n }\n\n return joinPaths(base, path);\n}\n\n// ─── Location helpers (formerly in resolve.ts) ────────────────────────────────\n\nexport function stripBase(pathname: string, base = '/'): string {\n const normalizedPath = normalizePath(pathname);\n const normalizedBase = normalizePath(base);\n\n if (normalizedBase === '/') return normalizedPath;\n\n if (normalizedPath === normalizedBase) return '/';\n\n const prefix = `${normalizedBase}/`;\n\n return normalizedPath.startsWith(prefix)\n ? normalizePath(normalizedPath.slice(normalizedBase.length))\n : normalizedPath;\n}\n\nexport function readLocation(\n base: string,\n history: { location: { hash: string; pathname: string; search: string; state: unknown } },\n): RouteLocation {\n return {\n hash: history.location.hash.replace(/^#/, ''),\n historyState: history.location.state,\n pathname: stripBase(history.location.pathname || '/', base),\n query: parseQuery(history.location.search || ''),\n };\n}\n"],"mappings":"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"}
package/dist/path.d.ts CHANGED
@@ -15,7 +15,7 @@ export declare function matchesPrefix(pathname: string, record: RouteRecord): bo
15
15
  * @security Query keys come directly from the (attacker-controlled) URL. Plain bracket
16
16
  * assignment (`out[key] = value`) would let a key like `__proto__` reach the inherited
17
17
  * `Object.prototype.__proto__` setter and silently rewrite `out`'s own prototype instead of
18
- * storing a value — `setQueryValue` uses `Object.defineProperty` so every key, including
18
+ * storing a value: `setQueryValue` uses `Object.defineProperty` so every key, including
19
19
  * `__proto__`/`constructor`/`prototype`, is always stored as a plain own data property.
20
20
  */
21
21
  export declare function parseQuery(queryString: string): QueryParams;
package/dist/path.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"path.js","names":[],"sources":["../src/path.ts"],"sourcesContent":["import { WayfinderConfigError, WayfinderRouteError } from './errors';\nimport type { QueryParams, ResolvedQueryParams, RouteLocation, RouteMatcher, RouteParams, RouteRecord } from './types';\n\n/** Ensure leading slash, collapse duplicate slashes, preserve root. */\nexport function normalizePath(path: string): string {\n const normalized = `/${path}`.replace(/\\/+/g, '/');\n\n return normalized !== '/' && normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;\n}\n\nexport function joinPaths(base: string, path: string): string {\n return normalizePath(`${base}/${path}`);\n}\n\nfunction escapeRegex(input: string): string {\n return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nexport function compilePathMatcher(path: string): RouteMatcher {\n const normalized = normalizePath(path);\n\n if (normalized === '/') {\n return {\n paramNames: [],\n pattern: /^\\/$/,\n prefixPattern: /^\\/$/,\n };\n }\n\n const paramNames: string[] = [];\n const segments = normalized.slice(1).split('/');\n const regexParts: string[] = ['^'];\n const prefixParts: string[] = ['^'];\n\n for (let i = 0; i < segments.length; i += 1) {\n const segment = segments[i]!;\n\n if (segment === '*') {\n if (i !== segments.length - 1) {\n throw new WayfinderConfigError(`Wildcard \"*\" must be the final segment in path: ${path}`);\n }\n\n regexParts.push('(?:/.*)?');\n break;\n }\n\n if (segment.startsWith(':') && segment.endsWith('*')) {\n if (i !== segments.length - 1) {\n throw new WayfinderConfigError(`Wildcard param must be final segment in path: ${path}`);\n }\n\n const name = segment.slice(1, -1);\n\n if (!/^\\w+$/.test(name)) {\n throw new WayfinderConfigError(\n `Invalid param name \":${name}\" in path \"${path}\". Param names must only contain word characters (a–z, A–Z, 0–9, _).`,\n );\n }\n\n paramNames.push(name);\n regexParts.push(i === segments.length - 1 ? '(?:/(.*))?' : '/(.*)');\n prefixParts.push('/.*');\n break;\n }\n\n if (segment.startsWith(':')) {\n const name = segment.slice(1);\n\n if (!/^\\w+$/.test(name)) {\n throw new WayfinderConfigError(\n `Invalid param name \":${name}\" in path \"${path}\". Param names must only contain word characters (a–z, A–Z, 0–9, _).`,\n );\n }\n\n paramNames.push(name);\n regexParts.push('/([^/]+)');\n prefixParts.push('/[^/]+');\n continue;\n }\n\n const escaped = escapeRegex(segment);\n\n regexParts.push(`/${escaped}`);\n prefixParts.push(`/${escaped}`);\n }\n\n regexParts.push('$');\n prefixParts.push('(?:/.*)?$');\n\n return {\n paramNames,\n pattern: new RegExp(regexParts.join('')),\n prefixPattern: new RegExp(prefixParts.join('')),\n };\n}\n\nexport function matchRecord(pathname: string, record: RouteRecord): RouteParams | null {\n const match = record.matcher.pattern.exec(pathname);\n\n if (!match) return null;\n\n const params: RouteParams = {};\n\n record.matcher.paramNames.forEach((name: string, index: number) => {\n const value = match[index + 1] ?? '';\n\n try {\n params[name] = decodeURIComponent(value);\n } catch {\n params[name] = value;\n }\n });\n\n return params;\n}\n\nexport function matchRouteFor(\n pathname: string,\n records: readonly RouteRecord[],\n): { params: RouteParams; record?: RouteRecord } {\n for (const record of records) {\n const params = matchRecord(pathname, record);\n\n if (params) return { params, record };\n }\n\n return { params: {} };\n}\n\nexport function matchesPrefix(pathname: string, record: RouteRecord): boolean {\n return record.matcher.prefixPattern.test(pathname);\n}\n\n/**\n * Parse `?foo=a&foo=b&bar=c` into `{ foo: ['a', 'b'], bar: 'c' }`.\n *\n * @security Query keys come directly from the (attacker-controlled) URL. Plain bracket\n * assignment (`out[key] = value`) would let a key like `__proto__` reach the inherited\n * `Object.prototype.__proto__` setter and silently rewrite `out`'s own prototype instead of\n * storing a value — `setQueryValue` uses `Object.defineProperty` so every key, including\n * `__proto__`/`constructor`/`prototype`, is always stored as a plain own data property.\n */\nexport function parseQuery(queryString: string): QueryParams {\n const search = new URLSearchParams(queryString);\n const out: QueryParams = {};\n\n const setQueryValue = (key: string, value: QueryParams[string]): void => {\n Object.defineProperty(out, key, { configurable: true, enumerable: true, value, writable: true });\n };\n\n for (const [key, value] of search.entries()) {\n const existing = Object.hasOwn(out, key) ? out[key] : undefined;\n\n if (existing === undefined) {\n setQueryValue(key, value);\n continue;\n }\n\n if (Array.isArray(existing)) {\n existing.push(value);\n continue;\n }\n\n setQueryValue(key, [existing, value]);\n }\n\n return out;\n}\n\n/** Build a URL from a path pattern, params, and query, respecting the router base. */\nexport function buildUrl(base: string, pattern: string, params: RouteParams = {}, query?: ResolvedQueryParams): string {\n let path = pattern.replace(/:(\\w+)(\\*)?/g, (_match, key: string, isWildcard: string) => {\n const value = params[key];\n\n if (value == null) throw new WayfinderRouteError(`Missing path param: ${key}`);\n\n if (isWildcard) {\n return value\n .split('/')\n .map((segment) => encodeURIComponent(segment))\n .join('/');\n }\n\n return encodeURIComponent(value);\n });\n\n if (query && Object.keys(query).length) {\n const search = new URLSearchParams();\n\n Object.entries(query).forEach(([key, value]) => {\n if (Array.isArray(value))\n value.forEach((item) => {\n search.append(key, String(item));\n });\n else search.set(key, String(value));\n });\n\n path += `?${search.toString()}`;\n }\n\n return joinPaths(base, path);\n}\n\n// ─── Location helpers (formerly in resolve.ts) ────────────────────────────────\n\nexport function stripBase(pathname: string, base = '/'): string {\n const normalizedPath = normalizePath(pathname);\n const normalizedBase = normalizePath(base);\n\n if (normalizedBase === '/') return normalizedPath;\n\n if (normalizedPath === normalizedBase) return '/';\n\n const prefix = `${normalizedBase}/`;\n\n return normalizedPath.startsWith(prefix)\n ? normalizePath(normalizedPath.slice(normalizedBase.length))\n : normalizedPath;\n}\n\nexport function readLocation(\n base: string,\n history: { location: { hash: string; pathname: string; search: string; state: unknown } },\n): RouteLocation {\n return {\n hash: history.location.hash.replace(/^#/, ''),\n historyState: history.location.state,\n pathname: stripBase(history.location.pathname || '/', base),\n query: parseQuery(history.location.search || ''),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"path.js","names":[],"sources":["../src/path.ts"],"sourcesContent":["import { WayfinderConfigError, WayfinderRouteError } from './errors';\nimport type { QueryParams, ResolvedQueryParams, RouteLocation, RouteMatcher, RouteParams, RouteRecord } from './types';\n\n/** Ensure leading slash, collapse duplicate slashes, preserve root. */\nexport function normalizePath(path: string): string {\n const normalized = `/${path}`.replace(/\\/+/g, '/');\n\n return normalized !== '/' && normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;\n}\n\nexport function joinPaths(base: string, path: string): string {\n return normalizePath(`${base}/${path}`);\n}\n\nfunction escapeRegex(input: string): string {\n return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nexport function compilePathMatcher(path: string): RouteMatcher {\n const normalized = normalizePath(path);\n\n if (normalized === '/') {\n return {\n paramNames: [],\n pattern: /^\\/$/,\n prefixPattern: /^\\/$/,\n };\n }\n\n const paramNames: string[] = [];\n const segments = normalized.slice(1).split('/');\n const regexParts: string[] = ['^'];\n const prefixParts: string[] = ['^'];\n\n for (let i = 0; i < segments.length; i += 1) {\n const segment = segments[i]!;\n\n if (segment === '*') {\n if (i !== segments.length - 1) {\n throw new WayfinderConfigError(`Wildcard \"*\" must be the final segment in path: ${path}`);\n }\n\n regexParts.push('(?:/.*)?');\n break;\n }\n\n if (segment.startsWith(':') && segment.endsWith('*')) {\n if (i !== segments.length - 1) {\n throw new WayfinderConfigError(`Wildcard param must be final segment in path: ${path}`);\n }\n\n const name = segment.slice(1, -1);\n\n if (!/^\\w+$/.test(name)) {\n throw new WayfinderConfigError(\n `Invalid param name \":${name}\" in path \"${path}\". Param names must only contain word characters (a–z, A–Z, 0–9, _).`,\n );\n }\n\n paramNames.push(name);\n regexParts.push(i === segments.length - 1 ? '(?:/(.*))?' : '/(.*)');\n prefixParts.push('/.*');\n break;\n }\n\n if (segment.startsWith(':')) {\n const name = segment.slice(1);\n\n if (!/^\\w+$/.test(name)) {\n throw new WayfinderConfigError(\n `Invalid param name \":${name}\" in path \"${path}\". Param names must only contain word characters (a–z, A–Z, 0–9, _).`,\n );\n }\n\n paramNames.push(name);\n regexParts.push('/([^/]+)');\n prefixParts.push('/[^/]+');\n continue;\n }\n\n const escaped = escapeRegex(segment);\n\n regexParts.push(`/${escaped}`);\n prefixParts.push(`/${escaped}`);\n }\n\n regexParts.push('$');\n prefixParts.push('(?:/.*)?$');\n\n return {\n paramNames,\n pattern: new RegExp(regexParts.join('')),\n prefixPattern: new RegExp(prefixParts.join('')),\n };\n}\n\nexport function matchRecord(pathname: string, record: RouteRecord): RouteParams | null {\n const match = record.matcher.pattern.exec(pathname);\n\n if (!match) return null;\n\n const params: RouteParams = {};\n\n record.matcher.paramNames.forEach((name: string, index: number) => {\n const value = match[index + 1] ?? '';\n\n try {\n params[name] = decodeURIComponent(value);\n } catch {\n params[name] = value;\n }\n });\n\n return params;\n}\n\nexport function matchRouteFor(\n pathname: string,\n records: readonly RouteRecord[],\n): { params: RouteParams; record?: RouteRecord } {\n for (const record of records) {\n const params = matchRecord(pathname, record);\n\n if (params) return { params, record };\n }\n\n return { params: {} };\n}\n\nexport function matchesPrefix(pathname: string, record: RouteRecord): boolean {\n return record.matcher.prefixPattern.test(pathname);\n}\n\n/**\n * Parse `?foo=a&foo=b&bar=c` into `{ foo: ['a', 'b'], bar: 'c' }`.\n *\n * @security Query keys come directly from the (attacker-controlled) URL. Plain bracket\n * assignment (`out[key] = value`) would let a key like `__proto__` reach the inherited\n * `Object.prototype.__proto__` setter and silently rewrite `out`'s own prototype instead of\n * storing a value: `setQueryValue` uses `Object.defineProperty` so every key, including\n * `__proto__`/`constructor`/`prototype`, is always stored as a plain own data property.\n */\nexport function parseQuery(queryString: string): QueryParams {\n const search = new URLSearchParams(queryString);\n const out: QueryParams = {};\n\n const setQueryValue = (key: string, value: QueryParams[string]): void => {\n Object.defineProperty(out, key, { configurable: true, enumerable: true, value, writable: true });\n };\n\n for (const [key, value] of search.entries()) {\n const existing = Object.hasOwn(out, key) ? out[key] : undefined;\n\n if (existing === undefined) {\n setQueryValue(key, value);\n continue;\n }\n\n if (Array.isArray(existing)) {\n existing.push(value);\n continue;\n }\n\n setQueryValue(key, [existing, value]);\n }\n\n return out;\n}\n\n/** Build a URL from a path pattern, params, and query, respecting the router base. */\nexport function buildUrl(base: string, pattern: string, params: RouteParams = {}, query?: ResolvedQueryParams): string {\n let path = pattern.replace(/:(\\w+)(\\*)?/g, (_match, key: string, isWildcard: string) => {\n const value = params[key];\n\n if (value == null) throw new WayfinderRouteError(`Missing path param: ${key}`);\n\n if (isWildcard) {\n return value\n .split('/')\n .map((segment) => encodeURIComponent(segment))\n .join('/');\n }\n\n return encodeURIComponent(value);\n });\n\n if (query && Object.keys(query).length) {\n const search = new URLSearchParams();\n\n Object.entries(query).forEach(([key, value]) => {\n if (Array.isArray(value))\n value.forEach((item) => {\n search.append(key, String(item));\n });\n else search.set(key, String(value));\n });\n\n path += `?${search.toString()}`;\n }\n\n return joinPaths(base, path);\n}\n\n// ─── Location helpers (formerly in resolve.ts) ────────────────────────────────\n\nexport function stripBase(pathname: string, base = '/'): string {\n const normalizedPath = normalizePath(pathname);\n const normalizedBase = normalizePath(base);\n\n if (normalizedBase === '/') return normalizedPath;\n\n if (normalizedPath === normalizedBase) return '/';\n\n const prefix = `${normalizedBase}/`;\n\n return normalizedPath.startsWith(prefix)\n ? normalizePath(normalizedPath.slice(normalizedBase.length))\n : normalizedPath;\n}\n\nexport function readLocation(\n base: string,\n history: { location: { hash: string; pathname: string; search: string; state: unknown } },\n): RouteLocation {\n return {\n hash: history.location.hash.replace(/^#/, ''),\n historyState: history.location.state,\n pathname: stripBase(history.location.pathname || '/', base),\n query: parseQuery(history.location.search || ''),\n };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"phase-mirror.cjs","names":[],"sources":["../src/phase-mirror.ts"],"sourcesContent":["import { computed, type Readable, watch } from '@vielzeug/ripple';\n\nimport { createRouteSignals } from './route-signals';\nimport type { Router } from './router';\n\n/**\n * Mirrors a domain state machine's phase into the URL as `/:subject/:id/:phase`.\n *\n * The mirror never writes history: every phase transition REPLACES the route, so the browser\n * back button leaves the flow rather than stepping between phases. Arriving at a mismatched\n * phase route is a revisit request (the consumer owns its confirm dialog) or an impossible\n * jump, which bounces back to the actual phase. The bare detail URL canonicalizes onto the\n * current phase's route, so every arrival is shareable and reload-safe. The state machine\n * stays the sole source of truth — `canRevisit` decides which mismatched routes may ask and\n * which bounce.\n */\nexport interface PhaseMirrorOptions<Phase extends string> {\n /** Whether the domain may revisit `target` from `current`. */\n readonly canRevisit: (current: Phase, target: Phase) => boolean;\n /** Reads the domain's current phase; `null` while the subject is missing. */\n readonly currentPhase: Readable<Phase | null>;\n /** Bare detail route name that canonicalizes onto the phase route. */\n readonly detailRoute: string;\n /** Route name carrying the `:phase` segment. */\n readonly phaseRoute: string;\n /** Valid phase values; the route segment is validated against this list. */\n readonly phases: readonly Phase[];\n /** Runs the revisit the URL asked for — the consumer owns its confirm dialog and command. */\n readonly requestRevisit: (phase: Phase) => void;\n /**\n * Any wayfinder router instance. The mirror only navigates by route name with runtime-shaped\n * params, so it deliberately erases the route-table generic instead of propagating it.\n */\n readonly router: Router<any>;\n /** Reads the subject id; `null` while the subject is missing. */\n readonly subjectId: Readable<string | null>;\n}\n\nexport interface PhaseMirror<Phase extends string> {\n /** Bounces a mismatched phase route back to the domain's actual phase — the dialog cancel path. */\n readonly bounceIfMismatched: () => void;\n /** Stops mirroring; the URL keeps whatever the last navigation left behind. */\n readonly dispose: () => void;\n /** Follows a tracker revisit link without writing history. */\n readonly followPhase: (phase: string) => void;\n /** Anchor href for one phase step; `#` while the subject is missing. */\n readonly phaseHref: (phase: string) => string;\n /** The phases the current phase may legally revisit — the tracker links exactly these. */\n readonly revisitPhases: Readable<readonly Phase[]>;\n /** The phase segment of the current route, when it names a valid phase. */\n readonly routePhase: Readable<Phase | null>;\n}\n\n/** Creates the two-way phase mirror between a domain state machine and a route segment. */\nexport function createPhaseMirror<Phase extends string>(options: PhaseMirrorOptions<Phase>): PhaseMirror<Phase> {\n const { name: routeName, params: routeParams } = createRouteSignals(options.router);\n\n const routePhase = computed<Phase | null>(() => {\n const segment = routeParams.value.phase as Phase | undefined;\n return segment !== undefined && options.phases.includes(segment) ? segment : null;\n });\n const revisitPhases = computed<readonly Phase[]>(() => {\n const phase = options.currentPhase.value;\n return phase ? options.phases.filter((entry) => options.canRevisit(phase, entry)) : [];\n });\n\n /** Lands on the phase's route, replacing so back exits the flow. */\n const goToPhase = (phase: string): void => {\n const id = options.subjectId.value;\n if (!id) return;\n void options.router.navigate({ name: options.phaseRoute, params: { id, phase } } as never, { replace: true });\n };\n\n const handles = [\n // The bare detail URL canonicalizes to the current phase's route.\n watch(\n () => `${options.subjectId.value ?? ''}|${routeName.value ?? ''}`,\n () => {\n const id = options.subjectId.value;\n const phase = options.currentPhase.value;\n // The URL names a different subject of the same flow — a cross-instance navigation.\n // Canonicalizing it here would stomp the arrival onto this mirror's own subject;\n // the arriving view's own mirror takes over from here.\n const urlSubjectId = routeParams.value.id;\n if (typeof urlSubjectId === 'string' && urlSubjectId !== id) return;\n if (routeName.value === options.detailRoute && id && phase) goToPhase(phase);\n },\n { immediate: true },\n ),\n // Domain → URL: every phase change lands on the phase's route.\n watch(options.currentPhase, (phase, previous) => {\n if (!phase || phase === previous) return;\n if (routePhase.value === phase) return;\n goToPhase(phase);\n }),\n // URL → domain: a mismatched or invalid phase route revisits (through the consumer's\n // dialog) or bounces. Reacts to the URL itself and to the domain loading (null → value,\n // which re-checks the mismatch a cold deep link brought in) — never to domain-led changes:\n // an advance or a confirmed revisit swaps the domain before the URL catches up, and reading\n // the not-yet-updated URL as a revisit request would open the dialog on every advance.\n watch(\n () => [routePhase.value, options.currentPhase.value] as const,\n ([phase, current], previous) => {\n // The user left the stepped flow entirely — the mirror stops following. Ripple\n // watchers fire synchronously on route change (before the component unmounts), so\n // a departure reads as \"phase vanished\" and would bounce back without this guard.\n if (routeName.value !== options.phaseRoute && routeName.value !== options.detailRoute) return;\n // The URL names a different subject of the same flow — a cross-instance navigation,\n // not a phase mismatch. The old mirror's component unmounts right after this.\n const urlSubjectId = routeParams.value.id;\n if (typeof urlSubjectId === 'string' && urlSubjectId !== options.subjectId.value) return;\n const previousCurrent = previous?.[1] ?? null;\n // A domain-led change (value → value) belongs to the domain → URL watcher above.\n if (current !== previousCurrent && previousCurrent !== null) return;\n const id = options.subjectId.value;\n if (!id || !current) return;\n if (phase === current) return;\n if (phase && options.canRevisit(current, phase)) {\n options.requestRevisit(phase);\n return;\n }\n goToPhase(current);\n },\n { immediate: true },\n ),\n ];\n\n return {\n bounceIfMismatched: () => {\n const current = options.currentPhase.value;\n if (options.subjectId.value && current && routePhase.value !== null && routePhase.value !== current) {\n goToPhase(current);\n }\n },\n dispose: () => {\n for (const handle of handles.splice(0)) handle.dispose();\n },\n followPhase: goToPhase,\n phaseHref: (phase: string): string => {\n const id = options.subjectId.value;\n return id ? options.router.href(options.phaseRoute as never, { id, phase } as never) : '#';\n },\n revisitPhases,\n routePhase,\n };\n}\n"],"mappings":"yEAsDA,SAAgB,kBAAwC,EAAwD,CAC9G,GAAM,CAAE,KAAM,EAAW,OAAQ,GAAgB,EAAA,mBAAmB,EAAQ,MAAM,EAE5E,GAAA,EAAa,EAAA,SAAA,KAA6B,CAC9C,IAAM,EAAU,EAAY,MAAM,MAClC,OAAO,IAAY,IAAA,IAAa,EAAQ,OAAO,SAAS,CAAO,EAAI,EAAU,IAC/E,CAAC,EACK,GAAA,EAAgB,EAAA,SAAA,KAAiC,CACrD,IAAM,EAAQ,EAAQ,aAAa,MACnC,OAAO,EAAQ,EAAQ,OAAO,OAAQ,GAAU,EAAQ,WAAW,EAAO,CAAK,CAAC,EAAI,CAAC,CACvF,CAAC,EAGK,UAAa,GAAwB,CACzC,IAAM,EAAK,EAAQ,UAAU,MACxB,GACL,EAAa,OAAO,SAAS,CAAE,KAAM,EAAQ,WAAY,OAAQ,CAAE,KAAI,OAAM,CAAE,EAAY,CAAE,QAAS,EAAK,CAAC,CAC9G,EAEM,EAAU,EAEd,EAAA,EAAA,MAAA,KACQ,GAAG,EAAQ,UAAU,OAAS,GAAG,GAAG,EAAU,OAAS,SACvD,CACJ,IAAM,EAAK,EAAQ,UAAU,MACvB,EAAQ,EAAQ,aAAa,MAI7B,EAAe,EAAY,MAAM,IACnC,OAAO,GAAiB,UAAY,IAAiB,IACrD,EAAU,QAAU,EAAQ,aAAe,GAAM,GAAO,UAAU,CAAK,CAC7E,EACA,CAAE,UAAW,EAAK,CACpB,GAEA,EAAA,EAAA,MAAA,CAAM,EAAQ,cAAe,EAAO,IAAa,CAC1C,GAAS,IAAU,GACpB,EAAW,QAAU,GACzB,UAAU,CAAK,CACjB,CAAC,GAMD,EAAA,EAAA,MAAA,KACQ,CAAC,EAAW,MAAO,EAAQ,aAAa,KAAK,GAClD,CAAC,EAAO,GAAU,IAAa,CAI9B,GAAI,EAAU,QAAU,EAAQ,YAAc,EAAU,QAAU,EAAQ,YAAa,OAGvF,IAAM,EAAe,EAAY,MAAM,GACvC,GAAI,OAAO,GAAiB,UAAY,IAAiB,EAAQ,UAAU,MAAO,OAClF,IAAM,EAAkB,IAAW,IAAM,KAErC,QAAY,GAAmB,IAAoB,OAC5C,EAAQ,UAAU,OACjB,GACR,IAAU,EACd,IAAI,GAAS,EAAQ,WAAW,EAAS,CAAK,EAAG,CAC/C,EAAQ,eAAe,CAAK,EAC5B,MACF,CACA,UAAU,CAAO,CADjB,CAEF,EACA,CAAE,UAAW,EAAK,CACpB,CACF,EAEA,MAAO,CACL,uBAA0B,CACxB,IAAM,EAAU,EAAQ,aAAa,MACjC,EAAQ,UAAU,OAAS,GAAW,EAAW,QAAU,MAAQ,EAAW,QAAU,GAC1F,UAAU,CAAO,CAErB,EACA,YAAe,CACb,IAAK,IAAM,KAAU,EAAQ,OAAO,CAAC,EAAG,EAAO,QAAQ,CACzD,EACA,YAAa,UACb,UAAY,GAA0B,CACpC,IAAM,EAAK,EAAQ,UAAU,MAC7B,OAAO,EAAK,EAAQ,OAAO,KAAK,EAAQ,WAAqB,CAAE,KAAI,OAAM,CAAU,EAAI,GACzF,EACA,gBACA,YACF,CACF"}
1
+ {"version":3,"file":"phase-mirror.cjs","names":[],"sources":["../src/phase-mirror.ts"],"sourcesContent":["import { computed, type Readable, watch } from '@vielzeug/ripple';\n\nimport { createRouteSignals } from './route-signals';\nimport type { Router } from './router';\n\n/**\n * Mirrors a domain state machine's phase into the URL as `/:subject/:id/:phase`.\n *\n * The mirror never writes history: every phase transition REPLACES the route, so the browser\n * back button leaves the flow rather than stepping between phases. Arriving at a mismatched\n * phase route is a revisit request (the consumer owns its confirm dialog) or an impossible\n * jump, which bounces back to the actual phase. The bare detail URL canonicalizes onto the\n * current phase's route, so every arrival is shareable and reload-safe. The state machine\n * stays the sole source of truth: `canRevisit` decides which mismatched routes may ask and\n * which bounce.\n */\nexport interface PhaseMirrorOptions<Phase extends string> {\n /** Whether the domain may revisit `target` from `current`. */\n readonly canRevisit: (current: Phase, target: Phase) => boolean;\n /** Reads the domain's current phase; `null` while the subject is missing. */\n readonly currentPhase: Readable<Phase | null>;\n /** Bare detail route name that canonicalizes onto the phase route. */\n readonly detailRoute: string;\n /** Route name carrying the `:phase` segment. */\n readonly phaseRoute: string;\n /** Valid phase values; the route segment is validated against this list. */\n readonly phases: readonly Phase[];\n /** Runs the revisit the URL asked for: the consumer owns its confirm dialog and command. */\n readonly requestRevisit: (phase: Phase) => void;\n /**\n * Any wayfinder router instance. The mirror only navigates by route name with runtime-shaped\n * params, so it deliberately erases the route-table generic instead of propagating it.\n */\n readonly router: Router<any>;\n /** Reads the subject id; `null` while the subject is missing. */\n readonly subjectId: Readable<string | null>;\n}\n\nexport interface PhaseMirror<Phase extends string> {\n /** Bounces a mismatched phase route back to the domain's actual phase: the dialog cancel path. */\n readonly bounceIfMismatched: () => void;\n /** Stops mirroring; the URL keeps whatever the last navigation left behind. */\n readonly dispose: () => void;\n /** Follows a tracker revisit link without writing history. */\n readonly followPhase: (phase: string) => void;\n /** Anchor href for one phase step; `#` while the subject is missing. */\n readonly phaseHref: (phase: string) => string;\n /** The phases the current phase may legally revisit: the tracker links exactly these. */\n readonly revisitPhases: Readable<readonly Phase[]>;\n /** The phase segment of the current route, when it names a valid phase. */\n readonly routePhase: Readable<Phase | null>;\n}\n\n/** Creates the two-way phase mirror between a domain state machine and a route segment. */\nexport function createPhaseMirror<Phase extends string>(options: PhaseMirrorOptions<Phase>): PhaseMirror<Phase> {\n const { name: routeName, params: routeParams } = createRouteSignals(options.router);\n\n const routePhase = computed<Phase | null>(() => {\n const segment = routeParams.value.phase as Phase | undefined;\n return segment !== undefined && options.phases.includes(segment) ? segment : null;\n });\n const revisitPhases = computed<readonly Phase[]>(() => {\n const phase = options.currentPhase.value;\n return phase ? options.phases.filter((entry) => options.canRevisit(phase, entry)) : [];\n });\n\n /** Lands on the phase's route, replacing so back exits the flow. */\n const goToPhase = (phase: string): void => {\n const id = options.subjectId.value;\n if (!id) return;\n void options.router.navigate({ name: options.phaseRoute, params: { id, phase } } as never, { replace: true });\n };\n\n const handles = [\n // The bare detail URL canonicalizes to the current phase's route.\n watch(\n () => `${options.subjectId.value ?? ''}|${routeName.value ?? ''}`,\n () => {\n const id = options.subjectId.value;\n const phase = options.currentPhase.value;\n // The URL names a different subject of the same flow: a cross-instance navigation.\n // Canonicalizing it here would stomp the arrival onto this mirror's own subject;\n // the arriving view's own mirror takes over from here.\n const urlSubjectId = routeParams.value.id;\n if (typeof urlSubjectId === 'string' && urlSubjectId !== id) return;\n if (routeName.value === options.detailRoute && id && phase) goToPhase(phase);\n },\n { immediate: true },\n ),\n // Domain → URL: every phase change lands on the phase's route.\n watch(options.currentPhase, (phase, previous) => {\n if (!phase || phase === previous) return;\n if (routePhase.value === phase) return;\n goToPhase(phase);\n }),\n // URL → domain: a mismatched or invalid phase route revisits (through the consumer's\n // dialog) or bounces. Reacts to the URL itself and to the domain loading (null → value,\n // which re-checks the mismatch a cold deep link brought in): never to domain-led changes:\n // an advance or a confirmed revisit swaps the domain before the URL catches up, and reading\n // the not-yet-updated URL as a revisit request would open the dialog on every advance.\n watch(\n () => [routePhase.value, options.currentPhase.value] as const,\n ([phase, current], previous) => {\n // The user left the stepped flow entirely: the mirror stops following. Ripple\n // watchers fire synchronously on route change (before the component unmounts), so\n // a departure reads as \"phase vanished\" and would bounce back without this guard.\n if (routeName.value !== options.phaseRoute && routeName.value !== options.detailRoute) return;\n // The URL names a different subject of the same flow: a cross-instance navigation,\n // not a phase mismatch. The old mirror's component unmounts right after this.\n const urlSubjectId = routeParams.value.id;\n if (typeof urlSubjectId === 'string' && urlSubjectId !== options.subjectId.value) return;\n const previousCurrent = previous?.[1] ?? null;\n // A domain-led change (value → value) belongs to the domain → URL watcher above.\n if (current !== previousCurrent && previousCurrent !== null) return;\n const id = options.subjectId.value;\n if (!id || !current) return;\n if (phase === current) return;\n if (phase && options.canRevisit(current, phase)) {\n options.requestRevisit(phase);\n return;\n }\n goToPhase(current);\n },\n { immediate: true },\n ),\n ];\n\n return {\n bounceIfMismatched: () => {\n const current = options.currentPhase.value;\n if (options.subjectId.value && current && routePhase.value !== null && routePhase.value !== current) {\n goToPhase(current);\n }\n },\n dispose: () => {\n for (const handle of handles.splice(0)) handle.dispose();\n },\n followPhase: goToPhase,\n phaseHref: (phase: string): string => {\n const id = options.subjectId.value;\n return id ? options.router.href(options.phaseRoute as never, { id, phase } as never) : '#';\n },\n revisitPhases,\n routePhase,\n };\n}\n"],"mappings":"yEAsDA,SAAgB,kBAAwC,EAAwD,CAC9G,GAAM,CAAE,KAAM,EAAW,OAAQ,GAAgB,EAAA,mBAAmB,EAAQ,MAAM,EAE5E,GAAA,EAAa,EAAA,SAAA,KAA6B,CAC9C,IAAM,EAAU,EAAY,MAAM,MAClC,OAAO,IAAY,IAAA,IAAa,EAAQ,OAAO,SAAS,CAAO,EAAI,EAAU,IAC/E,CAAC,EACK,GAAA,EAAgB,EAAA,SAAA,KAAiC,CACrD,IAAM,EAAQ,EAAQ,aAAa,MACnC,OAAO,EAAQ,EAAQ,OAAO,OAAQ,GAAU,EAAQ,WAAW,EAAO,CAAK,CAAC,EAAI,CAAC,CACvF,CAAC,EAGK,UAAa,GAAwB,CACzC,IAAM,EAAK,EAAQ,UAAU,MACxB,GACL,EAAa,OAAO,SAAS,CAAE,KAAM,EAAQ,WAAY,OAAQ,CAAE,KAAI,OAAM,CAAE,EAAY,CAAE,QAAS,EAAK,CAAC,CAC9G,EAEM,EAAU,EAEd,EAAA,EAAA,MAAA,KACQ,GAAG,EAAQ,UAAU,OAAS,GAAG,GAAG,EAAU,OAAS,SACvD,CACJ,IAAM,EAAK,EAAQ,UAAU,MACvB,EAAQ,EAAQ,aAAa,MAI7B,EAAe,EAAY,MAAM,IACnC,OAAO,GAAiB,UAAY,IAAiB,IACrD,EAAU,QAAU,EAAQ,aAAe,GAAM,GAAO,UAAU,CAAK,CAC7E,EACA,CAAE,UAAW,EAAK,CACpB,GAEA,EAAA,EAAA,MAAA,CAAM,EAAQ,cAAe,EAAO,IAAa,CAC1C,GAAS,IAAU,GACpB,EAAW,QAAU,GACzB,UAAU,CAAK,CACjB,CAAC,GAMD,EAAA,EAAA,MAAA,KACQ,CAAC,EAAW,MAAO,EAAQ,aAAa,KAAK,GAClD,CAAC,EAAO,GAAU,IAAa,CAI9B,GAAI,EAAU,QAAU,EAAQ,YAAc,EAAU,QAAU,EAAQ,YAAa,OAGvF,IAAM,EAAe,EAAY,MAAM,GACvC,GAAI,OAAO,GAAiB,UAAY,IAAiB,EAAQ,UAAU,MAAO,OAClF,IAAM,EAAkB,IAAW,IAAM,KAErC,QAAY,GAAmB,IAAoB,OAC5C,EAAQ,UAAU,OACjB,GACR,IAAU,EACd,IAAI,GAAS,EAAQ,WAAW,EAAS,CAAK,EAAG,CAC/C,EAAQ,eAAe,CAAK,EAC5B,MACF,CACA,UAAU,CAAO,CADjB,CAEF,EACA,CAAE,UAAW,EAAK,CACpB,CACF,EAEA,MAAO,CACL,uBAA0B,CACxB,IAAM,EAAU,EAAQ,aAAa,MACjC,EAAQ,UAAU,OAAS,GAAW,EAAW,QAAU,MAAQ,EAAW,QAAU,GAC1F,UAAU,CAAO,CAErB,EACA,YAAe,CACb,IAAK,IAAM,KAAU,EAAQ,OAAO,CAAC,EAAG,EAAO,QAAQ,CACzD,EACA,YAAa,UACb,UAAY,GAA0B,CACpC,IAAM,EAAK,EAAQ,UAAU,MAC7B,OAAO,EAAK,EAAQ,OAAO,KAAK,EAAQ,WAAqB,CAAE,KAAI,OAAM,CAAU,EAAI,GACzF,EACA,gBACA,YACF,CACF"}
@@ -8,7 +8,7 @@ import type { Router } from './router';
8
8
  * phase route is a revisit request (the consumer owns its confirm dialog) or an impossible
9
9
  * jump, which bounces back to the actual phase. The bare detail URL canonicalizes onto the
10
10
  * current phase's route, so every arrival is shareable and reload-safe. The state machine
11
- * stays the sole source of truth — `canRevisit` decides which mismatched routes may ask and
11
+ * stays the sole source of truth: `canRevisit` decides which mismatched routes may ask and
12
12
  * which bounce.
13
13
  */
14
14
  export interface PhaseMirrorOptions<Phase extends string> {
@@ -22,7 +22,7 @@ export interface PhaseMirrorOptions<Phase extends string> {
22
22
  readonly phaseRoute: string;
23
23
  /** Valid phase values; the route segment is validated against this list. */
24
24
  readonly phases: readonly Phase[];
25
- /** Runs the revisit the URL asked for — the consumer owns its confirm dialog and command. */
25
+ /** Runs the revisit the URL asked for: the consumer owns its confirm dialog and command. */
26
26
  readonly requestRevisit: (phase: Phase) => void;
27
27
  /**
28
28
  * Any wayfinder router instance. The mirror only navigates by route name with runtime-shaped
@@ -33,7 +33,7 @@ export interface PhaseMirrorOptions<Phase extends string> {
33
33
  readonly subjectId: Readable<string | null>;
34
34
  }
35
35
  export interface PhaseMirror<Phase extends string> {
36
- /** Bounces a mismatched phase route back to the domain's actual phase — the dialog cancel path. */
36
+ /** Bounces a mismatched phase route back to the domain's actual phase: the dialog cancel path. */
37
37
  readonly bounceIfMismatched: () => void;
38
38
  /** Stops mirroring; the URL keeps whatever the last navigation left behind. */
39
39
  readonly dispose: () => void;
@@ -41,7 +41,7 @@ export interface PhaseMirror<Phase extends string> {
41
41
  readonly followPhase: (phase: string) => void;
42
42
  /** Anchor href for one phase step; `#` while the subject is missing. */
43
43
  readonly phaseHref: (phase: string) => string;
44
- /** The phases the current phase may legally revisit — the tracker links exactly these. */
44
+ /** The phases the current phase may legally revisit: the tracker links exactly these. */
45
45
  readonly revisitPhases: Readable<readonly Phase[]>;
46
46
  /** The phase segment of the current route, when it names a valid phase. */
47
47
  readonly routePhase: Readable<Phase | null>;
@@ -1 +1 @@
1
- {"version":3,"file":"phase-mirror.d.ts","sourceRoot":"","sources":["../src/phase-mirror.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAS,MAAM,kBAAkB,CAAC;AAGlE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB,CAAC,KAAK,SAAS,MAAM;IACtD,8DAA8D;IAC9D,QAAQ,CAAC,UAAU,EAAE,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,KAAK,OAAO,CAAC;IAChE,6EAA6E;IAC7E,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IAC9C,sEAAsE;IACtE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,gDAAgD;IAChD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,SAAS,KAAK,EAAE,CAAC;IAClC,6FAA6F;IAC7F,QAAQ,CAAC,cAAc,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAChD;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,WAAW,CAAC,KAAK,SAAS,MAAM;IAC/C,mGAAmG;IACnG,QAAQ,CAAC,kBAAkB,EAAE,MAAM,IAAI,CAAC;IACxC,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAC;IAC7B,8DAA8D;IAC9D,QAAQ,CAAC,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,wEAAwE;IACxE,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9C,0FAA0F;IAC1F,QAAQ,CAAC,aAAa,EAAE,QAAQ,CAAC,SAAS,KAAK,EAAE,CAAC,CAAC;IACnD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;CAC7C;AAED,2FAA2F;AAC3F,wBAAgB,iBAAiB,CAAC,KAAK,SAAS,MAAM,EAAE,OAAO,EAAE,kBAAkB,CAAC,KAAK,CAAC,GAAG,WAAW,CAAC,KAAK,CAAC,CA2F9G"}
1
+ {"version":3,"file":"phase-mirror.d.ts","sourceRoot":"","sources":["../src/phase-mirror.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAS,MAAM,kBAAkB,CAAC;AAGlE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB,CAAC,KAAK,SAAS,MAAM;IACtD,8DAA8D;IAC9D,QAAQ,CAAC,UAAU,EAAE,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,KAAK,OAAO,CAAC;IAChE,6EAA6E;IAC7E,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IAC9C,sEAAsE;IACtE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,gDAAgD;IAChD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,SAAS,KAAK,EAAE,CAAC;IAClC,4FAA4F;IAC5F,QAAQ,CAAC,cAAc,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAChD;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,WAAW,CAAC,KAAK,SAAS,MAAM;IAC/C,kGAAkG;IAClG,QAAQ,CAAC,kBAAkB,EAAE,MAAM,IAAI,CAAC;IACxC,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAC;IAC7B,8DAA8D;IAC9D,QAAQ,CAAC,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,wEAAwE;IACxE,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9C,yFAAyF;IACzF,QAAQ,CAAC,aAAa,EAAE,QAAQ,CAAC,SAAS,KAAK,EAAE,CAAC,CAAC;IACnD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;CAC7C;AAED,2FAA2F;AAC3F,wBAAgB,iBAAiB,CAAC,KAAK,SAAS,MAAM,EAAE,OAAO,EAAE,kBAAkB,CAAC,KAAK,CAAC,GAAG,WAAW,CAAC,KAAK,CAAC,CA2F9G"}
@@ -1 +1 @@
1
- {"version":3,"file":"phase-mirror.js","names":[],"sources":["../src/phase-mirror.ts"],"sourcesContent":["import { computed, type Readable, watch } from '@vielzeug/ripple';\n\nimport { createRouteSignals } from './route-signals';\nimport type { Router } from './router';\n\n/**\n * Mirrors a domain state machine's phase into the URL as `/:subject/:id/:phase`.\n *\n * The mirror never writes history: every phase transition REPLACES the route, so the browser\n * back button leaves the flow rather than stepping between phases. Arriving at a mismatched\n * phase route is a revisit request (the consumer owns its confirm dialog) or an impossible\n * jump, which bounces back to the actual phase. The bare detail URL canonicalizes onto the\n * current phase's route, so every arrival is shareable and reload-safe. The state machine\n * stays the sole source of truth — `canRevisit` decides which mismatched routes may ask and\n * which bounce.\n */\nexport interface PhaseMirrorOptions<Phase extends string> {\n /** Whether the domain may revisit `target` from `current`. */\n readonly canRevisit: (current: Phase, target: Phase) => boolean;\n /** Reads the domain's current phase; `null` while the subject is missing. */\n readonly currentPhase: Readable<Phase | null>;\n /** Bare detail route name that canonicalizes onto the phase route. */\n readonly detailRoute: string;\n /** Route name carrying the `:phase` segment. */\n readonly phaseRoute: string;\n /** Valid phase values; the route segment is validated against this list. */\n readonly phases: readonly Phase[];\n /** Runs the revisit the URL asked for — the consumer owns its confirm dialog and command. */\n readonly requestRevisit: (phase: Phase) => void;\n /**\n * Any wayfinder router instance. The mirror only navigates by route name with runtime-shaped\n * params, so it deliberately erases the route-table generic instead of propagating it.\n */\n readonly router: Router<any>;\n /** Reads the subject id; `null` while the subject is missing. */\n readonly subjectId: Readable<string | null>;\n}\n\nexport interface PhaseMirror<Phase extends string> {\n /** Bounces a mismatched phase route back to the domain's actual phase — the dialog cancel path. */\n readonly bounceIfMismatched: () => void;\n /** Stops mirroring; the URL keeps whatever the last navigation left behind. */\n readonly dispose: () => void;\n /** Follows a tracker revisit link without writing history. */\n readonly followPhase: (phase: string) => void;\n /** Anchor href for one phase step; `#` while the subject is missing. */\n readonly phaseHref: (phase: string) => string;\n /** The phases the current phase may legally revisit — the tracker links exactly these. */\n readonly revisitPhases: Readable<readonly Phase[]>;\n /** The phase segment of the current route, when it names a valid phase. */\n readonly routePhase: Readable<Phase | null>;\n}\n\n/** Creates the two-way phase mirror between a domain state machine and a route segment. */\nexport function createPhaseMirror<Phase extends string>(options: PhaseMirrorOptions<Phase>): PhaseMirror<Phase> {\n const { name: routeName, params: routeParams } = createRouteSignals(options.router);\n\n const routePhase = computed<Phase | null>(() => {\n const segment = routeParams.value.phase as Phase | undefined;\n return segment !== undefined && options.phases.includes(segment) ? segment : null;\n });\n const revisitPhases = computed<readonly Phase[]>(() => {\n const phase = options.currentPhase.value;\n return phase ? options.phases.filter((entry) => options.canRevisit(phase, entry)) : [];\n });\n\n /** Lands on the phase's route, replacing so back exits the flow. */\n const goToPhase = (phase: string): void => {\n const id = options.subjectId.value;\n if (!id) return;\n void options.router.navigate({ name: options.phaseRoute, params: { id, phase } } as never, { replace: true });\n };\n\n const handles = [\n // The bare detail URL canonicalizes to the current phase's route.\n watch(\n () => `${options.subjectId.value ?? ''}|${routeName.value ?? ''}`,\n () => {\n const id = options.subjectId.value;\n const phase = options.currentPhase.value;\n // The URL names a different subject of the same flow — a cross-instance navigation.\n // Canonicalizing it here would stomp the arrival onto this mirror's own subject;\n // the arriving view's own mirror takes over from here.\n const urlSubjectId = routeParams.value.id;\n if (typeof urlSubjectId === 'string' && urlSubjectId !== id) return;\n if (routeName.value === options.detailRoute && id && phase) goToPhase(phase);\n },\n { immediate: true },\n ),\n // Domain → URL: every phase change lands on the phase's route.\n watch(options.currentPhase, (phase, previous) => {\n if (!phase || phase === previous) return;\n if (routePhase.value === phase) return;\n goToPhase(phase);\n }),\n // URL → domain: a mismatched or invalid phase route revisits (through the consumer's\n // dialog) or bounces. Reacts to the URL itself and to the domain loading (null → value,\n // which re-checks the mismatch a cold deep link brought in) — never to domain-led changes:\n // an advance or a confirmed revisit swaps the domain before the URL catches up, and reading\n // the not-yet-updated URL as a revisit request would open the dialog on every advance.\n watch(\n () => [routePhase.value, options.currentPhase.value] as const,\n ([phase, current], previous) => {\n // The user left the stepped flow entirely — the mirror stops following. Ripple\n // watchers fire synchronously on route change (before the component unmounts), so\n // a departure reads as \"phase vanished\" and would bounce back without this guard.\n if (routeName.value !== options.phaseRoute && routeName.value !== options.detailRoute) return;\n // The URL names a different subject of the same flow — a cross-instance navigation,\n // not a phase mismatch. The old mirror's component unmounts right after this.\n const urlSubjectId = routeParams.value.id;\n if (typeof urlSubjectId === 'string' && urlSubjectId !== options.subjectId.value) return;\n const previousCurrent = previous?.[1] ?? null;\n // A domain-led change (value → value) belongs to the domain → URL watcher above.\n if (current !== previousCurrent && previousCurrent !== null) return;\n const id = options.subjectId.value;\n if (!id || !current) return;\n if (phase === current) return;\n if (phase && options.canRevisit(current, phase)) {\n options.requestRevisit(phase);\n return;\n }\n goToPhase(current);\n },\n { immediate: true },\n ),\n ];\n\n return {\n bounceIfMismatched: () => {\n const current = options.currentPhase.value;\n if (options.subjectId.value && current && routePhase.value !== null && routePhase.value !== current) {\n goToPhase(current);\n }\n },\n dispose: () => {\n for (const handle of handles.splice(0)) handle.dispose();\n },\n followPhase: goToPhase,\n phaseHref: (phase: string): string => {\n const id = options.subjectId.value;\n return id ? options.router.href(options.phaseRoute as never, { id, phase } as never) : '#';\n },\n revisitPhases,\n routePhase,\n };\n}\n"],"mappings":"+GAsDA,SAAgB,kBAAwC,EAAwD,CAC9G,GAAM,CAAE,KAAM,EAAW,OAAQ,GAAgB,EAAmB,EAAQ,MAAM,EAE5E,EAAa,MAA6B,CAC9C,IAAM,EAAU,EAAY,MAAM,MAClC,OAAO,IAAY,IAAA,IAAa,EAAQ,OAAO,SAAS,CAAO,EAAI,EAAU,IAC/E,CAAC,EACK,EAAgB,MAAiC,CACrD,IAAM,EAAQ,EAAQ,aAAa,MACnC,OAAO,EAAQ,EAAQ,OAAO,OAAQ,GAAU,EAAQ,WAAW,EAAO,CAAK,CAAC,EAAI,CAAC,CACvF,CAAC,EAGK,UAAa,GAAwB,CACzC,IAAM,EAAK,EAAQ,UAAU,MACxB,GACL,EAAa,OAAO,SAAS,CAAE,KAAM,EAAQ,WAAY,OAAQ,CAAE,KAAI,OAAM,CAAE,EAAY,CAAE,QAAS,EAAK,CAAC,CAC9G,EAEM,EAAU,CAEd,MACQ,GAAG,EAAQ,UAAU,OAAS,GAAG,GAAG,EAAU,OAAS,SACvD,CACJ,IAAM,EAAK,EAAQ,UAAU,MACvB,EAAQ,EAAQ,aAAa,MAI7B,EAAe,EAAY,MAAM,IACnC,OAAO,GAAiB,UAAY,IAAiB,IACrD,EAAU,QAAU,EAAQ,aAAe,GAAM,GAAO,UAAU,CAAK,CAC7E,EACA,CAAE,UAAW,EAAK,CACpB,EAEA,EAAM,EAAQ,cAAe,EAAO,IAAa,CAC1C,GAAS,IAAU,GACpB,EAAW,QAAU,GACzB,UAAU,CAAK,CACjB,CAAC,EAMD,MACQ,CAAC,EAAW,MAAO,EAAQ,aAAa,KAAK,GAClD,CAAC,EAAO,GAAU,IAAa,CAI9B,GAAI,EAAU,QAAU,EAAQ,YAAc,EAAU,QAAU,EAAQ,YAAa,OAGvF,IAAM,EAAe,EAAY,MAAM,GACvC,GAAI,OAAO,GAAiB,UAAY,IAAiB,EAAQ,UAAU,MAAO,OAClF,IAAM,EAAkB,IAAW,IAAM,KAErC,QAAY,GAAmB,IAAoB,OAC5C,EAAQ,UAAU,OACjB,GACR,IAAU,EACd,IAAI,GAAS,EAAQ,WAAW,EAAS,CAAK,EAAG,CAC/C,EAAQ,eAAe,CAAK,EAC5B,MACF,CACA,UAAU,CAAO,CADjB,CAEF,EACA,CAAE,UAAW,EAAK,CACpB,CACF,EAEA,MAAO,CACL,uBAA0B,CACxB,IAAM,EAAU,EAAQ,aAAa,MACjC,EAAQ,UAAU,OAAS,GAAW,EAAW,QAAU,MAAQ,EAAW,QAAU,GAC1F,UAAU,CAAO,CAErB,EACA,YAAe,CACb,IAAK,IAAM,KAAU,EAAQ,OAAO,CAAC,EAAG,EAAO,QAAQ,CACzD,EACA,YAAa,UACb,UAAY,GAA0B,CACpC,IAAM,EAAK,EAAQ,UAAU,MAC7B,OAAO,EAAK,EAAQ,OAAO,KAAK,EAAQ,WAAqB,CAAE,KAAI,OAAM,CAAU,EAAI,GACzF,EACA,gBACA,YACF,CACF"}
1
+ {"version":3,"file":"phase-mirror.js","names":[],"sources":["../src/phase-mirror.ts"],"sourcesContent":["import { computed, type Readable, watch } from '@vielzeug/ripple';\n\nimport { createRouteSignals } from './route-signals';\nimport type { Router } from './router';\n\n/**\n * Mirrors a domain state machine's phase into the URL as `/:subject/:id/:phase`.\n *\n * The mirror never writes history: every phase transition REPLACES the route, so the browser\n * back button leaves the flow rather than stepping between phases. Arriving at a mismatched\n * phase route is a revisit request (the consumer owns its confirm dialog) or an impossible\n * jump, which bounces back to the actual phase. The bare detail URL canonicalizes onto the\n * current phase's route, so every arrival is shareable and reload-safe. The state machine\n * stays the sole source of truth: `canRevisit` decides which mismatched routes may ask and\n * which bounce.\n */\nexport interface PhaseMirrorOptions<Phase extends string> {\n /** Whether the domain may revisit `target` from `current`. */\n readonly canRevisit: (current: Phase, target: Phase) => boolean;\n /** Reads the domain's current phase; `null` while the subject is missing. */\n readonly currentPhase: Readable<Phase | null>;\n /** Bare detail route name that canonicalizes onto the phase route. */\n readonly detailRoute: string;\n /** Route name carrying the `:phase` segment. */\n readonly phaseRoute: string;\n /** Valid phase values; the route segment is validated against this list. */\n readonly phases: readonly Phase[];\n /** Runs the revisit the URL asked for: the consumer owns its confirm dialog and command. */\n readonly requestRevisit: (phase: Phase) => void;\n /**\n * Any wayfinder router instance. The mirror only navigates by route name with runtime-shaped\n * params, so it deliberately erases the route-table generic instead of propagating it.\n */\n readonly router: Router<any>;\n /** Reads the subject id; `null` while the subject is missing. */\n readonly subjectId: Readable<string | null>;\n}\n\nexport interface PhaseMirror<Phase extends string> {\n /** Bounces a mismatched phase route back to the domain's actual phase: the dialog cancel path. */\n readonly bounceIfMismatched: () => void;\n /** Stops mirroring; the URL keeps whatever the last navigation left behind. */\n readonly dispose: () => void;\n /** Follows a tracker revisit link without writing history. */\n readonly followPhase: (phase: string) => void;\n /** Anchor href for one phase step; `#` while the subject is missing. */\n readonly phaseHref: (phase: string) => string;\n /** The phases the current phase may legally revisit: the tracker links exactly these. */\n readonly revisitPhases: Readable<readonly Phase[]>;\n /** The phase segment of the current route, when it names a valid phase. */\n readonly routePhase: Readable<Phase | null>;\n}\n\n/** Creates the two-way phase mirror between a domain state machine and a route segment. */\nexport function createPhaseMirror<Phase extends string>(options: PhaseMirrorOptions<Phase>): PhaseMirror<Phase> {\n const { name: routeName, params: routeParams } = createRouteSignals(options.router);\n\n const routePhase = computed<Phase | null>(() => {\n const segment = routeParams.value.phase as Phase | undefined;\n return segment !== undefined && options.phases.includes(segment) ? segment : null;\n });\n const revisitPhases = computed<readonly Phase[]>(() => {\n const phase = options.currentPhase.value;\n return phase ? options.phases.filter((entry) => options.canRevisit(phase, entry)) : [];\n });\n\n /** Lands on the phase's route, replacing so back exits the flow. */\n const goToPhase = (phase: string): void => {\n const id = options.subjectId.value;\n if (!id) return;\n void options.router.navigate({ name: options.phaseRoute, params: { id, phase } } as never, { replace: true });\n };\n\n const handles = [\n // The bare detail URL canonicalizes to the current phase's route.\n watch(\n () => `${options.subjectId.value ?? ''}|${routeName.value ?? ''}`,\n () => {\n const id = options.subjectId.value;\n const phase = options.currentPhase.value;\n // The URL names a different subject of the same flow: a cross-instance navigation.\n // Canonicalizing it here would stomp the arrival onto this mirror's own subject;\n // the arriving view's own mirror takes over from here.\n const urlSubjectId = routeParams.value.id;\n if (typeof urlSubjectId === 'string' && urlSubjectId !== id) return;\n if (routeName.value === options.detailRoute && id && phase) goToPhase(phase);\n },\n { immediate: true },\n ),\n // Domain → URL: every phase change lands on the phase's route.\n watch(options.currentPhase, (phase, previous) => {\n if (!phase || phase === previous) return;\n if (routePhase.value === phase) return;\n goToPhase(phase);\n }),\n // URL → domain: a mismatched or invalid phase route revisits (through the consumer's\n // dialog) or bounces. Reacts to the URL itself and to the domain loading (null → value,\n // which re-checks the mismatch a cold deep link brought in): never to domain-led changes:\n // an advance or a confirmed revisit swaps the domain before the URL catches up, and reading\n // the not-yet-updated URL as a revisit request would open the dialog on every advance.\n watch(\n () => [routePhase.value, options.currentPhase.value] as const,\n ([phase, current], previous) => {\n // The user left the stepped flow entirely: the mirror stops following. Ripple\n // watchers fire synchronously on route change (before the component unmounts), so\n // a departure reads as \"phase vanished\" and would bounce back without this guard.\n if (routeName.value !== options.phaseRoute && routeName.value !== options.detailRoute) return;\n // The URL names a different subject of the same flow: a cross-instance navigation,\n // not a phase mismatch. The old mirror's component unmounts right after this.\n const urlSubjectId = routeParams.value.id;\n if (typeof urlSubjectId === 'string' && urlSubjectId !== options.subjectId.value) return;\n const previousCurrent = previous?.[1] ?? null;\n // A domain-led change (value → value) belongs to the domain → URL watcher above.\n if (current !== previousCurrent && previousCurrent !== null) return;\n const id = options.subjectId.value;\n if (!id || !current) return;\n if (phase === current) return;\n if (phase && options.canRevisit(current, phase)) {\n options.requestRevisit(phase);\n return;\n }\n goToPhase(current);\n },\n { immediate: true },\n ),\n ];\n\n return {\n bounceIfMismatched: () => {\n const current = options.currentPhase.value;\n if (options.subjectId.value && current && routePhase.value !== null && routePhase.value !== current) {\n goToPhase(current);\n }\n },\n dispose: () => {\n for (const handle of handles.splice(0)) handle.dispose();\n },\n followPhase: goToPhase,\n phaseHref: (phase: string): string => {\n const id = options.subjectId.value;\n return id ? options.router.href(options.phaseRoute as never, { id, phase } as never) : '#';\n },\n revisitPhases,\n routePhase,\n };\n}\n"],"mappings":"+GAsDA,SAAgB,kBAAwC,EAAwD,CAC9G,GAAM,CAAE,KAAM,EAAW,OAAQ,GAAgB,EAAmB,EAAQ,MAAM,EAE5E,EAAa,MAA6B,CAC9C,IAAM,EAAU,EAAY,MAAM,MAClC,OAAO,IAAY,IAAA,IAAa,EAAQ,OAAO,SAAS,CAAO,EAAI,EAAU,IAC/E,CAAC,EACK,EAAgB,MAAiC,CACrD,IAAM,EAAQ,EAAQ,aAAa,MACnC,OAAO,EAAQ,EAAQ,OAAO,OAAQ,GAAU,EAAQ,WAAW,EAAO,CAAK,CAAC,EAAI,CAAC,CACvF,CAAC,EAGK,UAAa,GAAwB,CACzC,IAAM,EAAK,EAAQ,UAAU,MACxB,GACL,EAAa,OAAO,SAAS,CAAE,KAAM,EAAQ,WAAY,OAAQ,CAAE,KAAI,OAAM,CAAE,EAAY,CAAE,QAAS,EAAK,CAAC,CAC9G,EAEM,EAAU,CAEd,MACQ,GAAG,EAAQ,UAAU,OAAS,GAAG,GAAG,EAAU,OAAS,SACvD,CACJ,IAAM,EAAK,EAAQ,UAAU,MACvB,EAAQ,EAAQ,aAAa,MAI7B,EAAe,EAAY,MAAM,IACnC,OAAO,GAAiB,UAAY,IAAiB,IACrD,EAAU,QAAU,EAAQ,aAAe,GAAM,GAAO,UAAU,CAAK,CAC7E,EACA,CAAE,UAAW,EAAK,CACpB,EAEA,EAAM,EAAQ,cAAe,EAAO,IAAa,CAC1C,GAAS,IAAU,GACpB,EAAW,QAAU,GACzB,UAAU,CAAK,CACjB,CAAC,EAMD,MACQ,CAAC,EAAW,MAAO,EAAQ,aAAa,KAAK,GAClD,CAAC,EAAO,GAAU,IAAa,CAI9B,GAAI,EAAU,QAAU,EAAQ,YAAc,EAAU,QAAU,EAAQ,YAAa,OAGvF,IAAM,EAAe,EAAY,MAAM,GACvC,GAAI,OAAO,GAAiB,UAAY,IAAiB,EAAQ,UAAU,MAAO,OAClF,IAAM,EAAkB,IAAW,IAAM,KAErC,QAAY,GAAmB,IAAoB,OAC5C,EAAQ,UAAU,OACjB,GACR,IAAU,EACd,IAAI,GAAS,EAAQ,WAAW,EAAS,CAAK,EAAG,CAC/C,EAAQ,eAAe,CAAK,EAC5B,MACF,CACA,UAAU,CAAO,CADjB,CAEF,EACA,CAAE,UAAW,EAAK,CACpB,CACF,EAEA,MAAO,CACL,uBAA0B,CACxB,IAAM,EAAU,EAAQ,aAAa,MACjC,EAAQ,UAAU,OAAS,GAAW,EAAW,QAAU,MAAQ,EAAW,QAAU,GAC1F,UAAU,CAAO,CAErB,EACA,YAAe,CACb,IAAK,IAAM,KAAU,EAAQ,OAAO,CAAC,EAAG,EAAO,QAAQ,CACzD,EACA,YAAa,UACb,UAAY,GAA0B,CACpC,IAAM,EAAK,EAAQ,UAAU,MAC7B,OAAO,EAAK,EAAQ,OAAO,KAAK,EAAQ,WAAqB,CAAE,KAAI,OAAM,CAAU,EAAI,GACzF,EACA,gBACA,YACF,CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"route-signals.cjs","names":[],"sources":["../src/route-signals.ts"],"sourcesContent":["import { computed, fromSubscribable, type Readable } from '@vielzeug/ripple';\n\nimport type { Router } from './router';\nimport type { QueryParams, RouteName, RouteParams, RouteState, RouteTable } from './types';\n\n/** Ripple readables mirroring a router's live location. */\nexport interface RouteSignals<TRoutes extends RouteTable = RouteTable> {\n /** Name of the deepest matched route; `null` while nothing matches (e.g. a not-found render). */\n readonly name: Readable<RouteName<TRoutes> | null>;\n /** Path params of the deepest matched route. */\n readonly params: Readable<RouteParams>;\n /** Raw parsed query params of the current location — always string values from URL parsing. */\n readonly query: Readable<QueryParams>;\n /** The full router state — subscribe or derive further computeds from it. */\n readonly state: Readable<RouteState>;\n}\n\n/**\n * Mirrors a router into ripple readables so templates and computeds react to navigation.\n *\n * `router.getSnapshot()` alone is NOT ripple-reactive: reading it inside a `computed()`\n * computes once and never re-runs, since it registers no tracked dependency. This bridge\n * wraps `subscribe()`/`getSnapshot()` with `fromSubscribable` once, for every consumer.\n */\nexport function createRouteSignals<TRoutes extends RouteTable>(router: Router<TRoutes>): RouteSignals<TRoutes> {\n const state = fromSubscribable<RouteState>({\n getSnapshot: () => router.getSnapshot(),\n subscribe: (listener) => router.subscribe(() => listener()),\n });\n\n return {\n name: computed(() => (state.value.matches.at(-1)?.name ?? null) as RouteName<TRoutes> | null),\n params: computed(() => state.value.matches.at(-1)?.params ?? {}),\n query: computed(() => state.value.location.query),\n state,\n };\n}\n"],"mappings":"kCAwBA,SAAgB,mBAA+C,EAAgD,CAC7G,IAAM,GAAA,EAAQ,EAAA,iBAAA,CAA6B,CACzC,gBAAmB,EAAO,YAAY,EACtC,UAAY,GAAa,EAAO,cAAgB,EAAS,CAAC,CAC5D,CAAC,EAED,MAAO,CACL,MAAA,EAAM,EAAA,SAAA,KAAgB,EAAM,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,MAAQ,IAAkC,EAC5F,QAAA,EAAQ,EAAA,SAAA,KAAe,EAAM,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,QAAU,CAAC,CAAC,EAC/D,OAAA,EAAO,EAAA,SAAA,KAAe,EAAM,MAAM,SAAS,KAAK,EAChD,OACF,CACF"}
1
+ {"version":3,"file":"route-signals.cjs","names":[],"sources":["../src/route-signals.ts"],"sourcesContent":["import { computed, fromSubscribable, type Readable } from '@vielzeug/ripple';\n\nimport type { Router } from './router';\nimport type { QueryParams, RouteName, RouteParams, RouteState, RouteTable } from './types';\n\n/** Ripple readables mirroring a router's live location. */\nexport interface RouteSignals<TRoutes extends RouteTable = RouteTable> {\n /** Name of the deepest matched route; `null` while nothing matches (e.g. a not-found render). */\n readonly name: Readable<RouteName<TRoutes> | null>;\n /** Path params of the deepest matched route. */\n readonly params: Readable<RouteParams>;\n /** Raw parsed query params of the current location: always string values from URL parsing. */\n readonly query: Readable<QueryParams>;\n /** The full router state: subscribe or derive further computeds from it. */\n readonly state: Readable<RouteState>;\n}\n\n/**\n * Mirrors a router into ripple readables so templates and computeds react to navigation.\n *\n * `router.getSnapshot()` alone is NOT ripple-reactive: reading it inside a `computed()`\n * computes once and never re-runs, since it registers no tracked dependency. This bridge\n * wraps `subscribe()`/`getSnapshot()` with `fromSubscribable` once, for every consumer.\n */\nexport function createRouteSignals<TRoutes extends RouteTable>(router: Router<TRoutes>): RouteSignals<TRoutes> {\n const state = fromSubscribable<RouteState>({\n getSnapshot: () => router.getSnapshot(),\n subscribe: (listener) => router.subscribe(() => listener()),\n });\n\n return {\n name: computed(() => (state.value.matches.at(-1)?.name ?? null) as RouteName<TRoutes> | null),\n params: computed(() => state.value.matches.at(-1)?.params ?? {}),\n query: computed(() => state.value.location.query),\n state,\n };\n}\n"],"mappings":"kCAwBA,SAAgB,mBAA+C,EAAgD,CAC7G,IAAM,GAAA,EAAQ,EAAA,iBAAA,CAA6B,CACzC,gBAAmB,EAAO,YAAY,EACtC,UAAY,GAAa,EAAO,cAAgB,EAAS,CAAC,CAC5D,CAAC,EAED,MAAO,CACL,MAAA,EAAM,EAAA,SAAA,KAAgB,EAAM,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,MAAQ,IAAkC,EAC5F,QAAA,EAAQ,EAAA,SAAA,KAAe,EAAM,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,QAAU,CAAC,CAAC,EAC/D,OAAA,EAAO,EAAA,SAAA,KAAe,EAAM,MAAM,SAAS,KAAK,EAChD,OACF,CACF"}
@@ -7,9 +7,9 @@ export interface RouteSignals<TRoutes extends RouteTable = RouteTable> {
7
7
  readonly name: Readable<RouteName<TRoutes> | null>;
8
8
  /** Path params of the deepest matched route. */
9
9
  readonly params: Readable<RouteParams>;
10
- /** Raw parsed query params of the current location — always string values from URL parsing. */
10
+ /** Raw parsed query params of the current location: always string values from URL parsing. */
11
11
  readonly query: Readable<QueryParams>;
12
- /** The full router state — subscribe or derive further computeds from it. */
12
+ /** The full router state: subscribe or derive further computeds from it. */
13
13
  readonly state: Readable<RouteState>;
14
14
  }
15
15
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"route-signals.d.ts","sourceRoot":"","sources":["../src/route-signals.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8B,KAAK,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE3F,2DAA2D;AAC3D,MAAM,WAAW,YAAY,CAAC,OAAO,SAAS,UAAU,GAAG,UAAU;IACnE,iGAAiG;IACjG,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;IACnD,gDAAgD;IAChD,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IACvC,+FAA+F;IAC/F,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,YAAY,CAAC,OAAO,CAAC,CAY7G"}
1
+ {"version":3,"file":"route-signals.d.ts","sourceRoot":"","sources":["../src/route-signals.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8B,KAAK,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE3F,2DAA2D;AAC3D,MAAM,WAAW,YAAY,CAAC,OAAO,SAAS,UAAU,GAAG,UAAU;IACnE,iGAAiG;IACjG,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;IACnD,gDAAgD;IAChD,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IACvC,8FAA8F;IAC9F,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IACtC,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,SAAS,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,YAAY,CAAC,OAAO,CAAC,CAY7G"}
@@ -1 +1 @@
1
- {"version":3,"file":"route-signals.js","names":[],"sources":["../src/route-signals.ts"],"sourcesContent":["import { computed, fromSubscribable, type Readable } from '@vielzeug/ripple';\n\nimport type { Router } from './router';\nimport type { QueryParams, RouteName, RouteParams, RouteState, RouteTable } from './types';\n\n/** Ripple readables mirroring a router's live location. */\nexport interface RouteSignals<TRoutes extends RouteTable = RouteTable> {\n /** Name of the deepest matched route; `null` while nothing matches (e.g. a not-found render). */\n readonly name: Readable<RouteName<TRoutes> | null>;\n /** Path params of the deepest matched route. */\n readonly params: Readable<RouteParams>;\n /** Raw parsed query params of the current location — always string values from URL parsing. */\n readonly query: Readable<QueryParams>;\n /** The full router state — subscribe or derive further computeds from it. */\n readonly state: Readable<RouteState>;\n}\n\n/**\n * Mirrors a router into ripple readables so templates and computeds react to navigation.\n *\n * `router.getSnapshot()` alone is NOT ripple-reactive: reading it inside a `computed()`\n * computes once and never re-runs, since it registers no tracked dependency. This bridge\n * wraps `subscribe()`/`getSnapshot()` with `fromSubscribable` once, for every consumer.\n */\nexport function createRouteSignals<TRoutes extends RouteTable>(router: Router<TRoutes>): RouteSignals<TRoutes> {\n const state = fromSubscribable<RouteState>({\n getSnapshot: () => router.getSnapshot(),\n subscribe: (listener) => router.subscribe(() => listener()),\n });\n\n return {\n name: computed(() => (state.value.matches.at(-1)?.name ?? null) as RouteName<TRoutes> | null),\n params: computed(() => state.value.matches.at(-1)?.params ?? {}),\n query: computed(() => state.value.location.query),\n state,\n };\n}\n"],"mappings":"kEAwBA,SAAgB,mBAA+C,EAAgD,CAC7G,IAAM,EAAQ,EAA6B,CACzC,gBAAmB,EAAO,YAAY,EACtC,UAAY,GAAa,EAAO,cAAgB,EAAS,CAAC,CAC5D,CAAC,EAED,MAAO,CACL,KAAM,MAAgB,EAAM,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,MAAQ,IAAkC,EAC5F,OAAQ,MAAe,EAAM,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,QAAU,CAAC,CAAC,EAC/D,MAAO,MAAe,EAAM,MAAM,SAAS,KAAK,EAChD,OACF,CACF"}
1
+ {"version":3,"file":"route-signals.js","names":[],"sources":["../src/route-signals.ts"],"sourcesContent":["import { computed, fromSubscribable, type Readable } from '@vielzeug/ripple';\n\nimport type { Router } from './router';\nimport type { QueryParams, RouteName, RouteParams, RouteState, RouteTable } from './types';\n\n/** Ripple readables mirroring a router's live location. */\nexport interface RouteSignals<TRoutes extends RouteTable = RouteTable> {\n /** Name of the deepest matched route; `null` while nothing matches (e.g. a not-found render). */\n readonly name: Readable<RouteName<TRoutes> | null>;\n /** Path params of the deepest matched route. */\n readonly params: Readable<RouteParams>;\n /** Raw parsed query params of the current location: always string values from URL parsing. */\n readonly query: Readable<QueryParams>;\n /** The full router state: subscribe or derive further computeds from it. */\n readonly state: Readable<RouteState>;\n}\n\n/**\n * Mirrors a router into ripple readables so templates and computeds react to navigation.\n *\n * `router.getSnapshot()` alone is NOT ripple-reactive: reading it inside a `computed()`\n * computes once and never re-runs, since it registers no tracked dependency. This bridge\n * wraps `subscribe()`/`getSnapshot()` with `fromSubscribable` once, for every consumer.\n */\nexport function createRouteSignals<TRoutes extends RouteTable>(router: Router<TRoutes>): RouteSignals<TRoutes> {\n const state = fromSubscribable<RouteState>({\n getSnapshot: () => router.getSnapshot(),\n subscribe: (listener) => router.subscribe(() => listener()),\n });\n\n return {\n name: computed(() => (state.value.matches.at(-1)?.name ?? null) as RouteName<TRoutes> | null),\n params: computed(() => state.value.matches.at(-1)?.params ?? {}),\n query: computed(() => state.value.location.query),\n state,\n };\n}\n"],"mappings":"kEAwBA,SAAgB,mBAA+C,EAAgD,CAC7G,IAAM,EAAQ,EAA6B,CACzC,gBAAmB,EAAO,YAAY,EACtC,UAAY,GAAa,EAAO,cAAgB,EAAS,CAAC,CAC5D,CAAC,EAED,MAAO,CACL,KAAM,MAAgB,EAAM,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,MAAQ,IAAkC,EAC5F,OAAQ,MAAe,EAAM,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,QAAU,CAAC,CAAC,EAC/D,MAAO,MAAe,EAAM,MAAM,SAAS,KAAK,EAChD,OACF,CACF"}