@zenithbuild/router 0.6.17 → 0.7.1

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
@@ -1,136 +1,20 @@
1
1
  # @zenith/router
2
2
 
3
- > **⚠️ Internal API:** This package is an internal implementation detail of the Zenith framework. It is not intended for public use and its API may break without warning. Please use `@zenithbuild/core` instead.
4
-
5
-
6
- File-based SPA router for Zenith framework with **deterministic, compile-time route resolution**.
3
+ > Internal Zenith package. The generated router runtime is the authoritative surface here, not a general SPA framework API.
7
4
 
8
5
  ## Canonical Docs
9
6
 
10
- - Routing contract: `../zenith-docs/documentation/contracts/routing.md`
11
- - Navigation contract: `../zenith-docs/documentation/contracts/navigation.md`
12
- - Router contract: `../zenith-docs/documentation/contracts/router-contract.md`
13
-
14
- ## Features
15
-
16
- - 📁 **File-based routing** — Pages in `pages/` directory become routes automatically
17
- - ⚡ **Compile-time resolution** — Route manifest generated at build time, not runtime
18
- - 🔗 **ZenLink component** — Declarative navigation with prefetching
19
- - 🧭 **Programmatic navigation** — `navigate()`, `prefetch()`, `isActive()` APIs
20
- - 🎯 **Type-safe** — Full TypeScript support with route parameter inference
21
- - 🚀 **Hydration-safe** — No runtime hacks, works seamlessly with SSR/SSG
22
-
23
- ## Installation
24
-
25
- ```bash
26
- bun add @zenith/router
27
- ```
28
-
29
- ## Usage
30
-
31
- ### Programmatic Navigation
32
-
33
- ```ts
34
- import { navigate, prefetch, isActive, getRoute } from '@zenith/router'
35
-
36
- // Navigate to a route
37
- navigate('/about')
38
-
39
- // Navigate with replace (no history entry)
40
- navigate('/dashboard', { replace: true })
41
-
42
- // Prefetch a route for faster navigation
43
- prefetch('/blog')
44
-
45
- // Check if a route is active
46
- if (isActive('/blog')) {
47
- console.log('Currently on blog section')
48
- }
49
-
50
- // Get current route state
51
- const { path, params, query } = getRoute()
52
- ```
53
-
54
- ### ZenLink Component (in .zen files)
55
-
56
- ```html
57
- <ZenLink href="/about">About Us</ZenLink>
58
-
59
- <!-- With prefetching on hover -->
60
- <ZenLink href="/blog" preload>Blog</ZenLink>
61
-
62
- <!-- External links automatically open in new tab -->
63
- <ZenLink href="https://github.com">GitHub</ZenLink>
64
- ```
65
-
66
- ### Build-time Route Manifest
67
-
68
- The router generates a route manifest at compile time:
69
-
70
- ```ts
71
- import { generateRouteManifest, discoverPages } from '@zenith/router/manifest'
72
-
73
- const pagesDir = './src/pages'
74
- const manifest = generateRouteManifest(pagesDir)
75
-
76
- // manifest contains:
77
- // - path: Route pattern (e.g., /blog/:id)
78
- // - regex: Compiled RegExp for matching
79
- // - paramNames: Dynamic segment names
80
- // - score: Priority for deterministic matching
81
- ```
82
-
83
- ## Route Patterns
84
-
85
- | File Path | Route Pattern |
86
- |-----------|---------------|
87
- | `pages/index.zen` | `/` |
88
- | `pages/about.zen` | `/about` |
89
- | `pages/blog/index.zen` | `/blog` |
90
- | `pages/blog/[id].zen` | `/blog/:id` |
91
- | `pages/posts/[...slug].zen` | `/posts/*slug` |
92
- | `pages/[[...all]].zen` | `/*all?` (optional) |
93
-
94
- ## Architecture
95
-
96
- ```
97
- @zenith/router
98
- ├── src/
99
- │ ├── index.ts # Main exports
100
- │ ├── types.ts # Core types
101
- │ ├── manifest.ts # Build-time manifest generation
102
- │ ├── runtime.ts # Client-side SPA router
103
- │ └── navigation/
104
- │ ├── index.ts # Navigation exports
105
- │ ├── zen-link.ts # Navigation API
106
- │ └── ZenLink.zen # Declarative component
107
- ```
108
-
109
- ## API Reference
110
-
111
- ### Navigation Functions
112
-
113
- - `navigate(path, options?)` — Navigate to a path
114
- - `prefetch(path)` — Prefetch a route for faster navigation
115
- - `isActive(path, exact?)` — Check if path is currently active
116
- - `getRoute()` — Get current route state
117
- - `back()`, `forward()`, `go(delta)` — History navigation
118
-
119
- ### Manifest Generation
120
-
121
- - `discoverPages(pagesDir)` — Find all .zen files in pages directory
122
- - `generateRouteManifest(pagesDir)` — Generate complete route manifest
123
- - `filePathToRoutePath(filePath, pagesDir)` — Convert file path to route
124
- - `routePathToRegex(routePath)` — Compile route to RegExp
125
-
126
- ### Types
127
-
128
- - `RouteState` — Current route state (path, params, query)
129
- - `RouteRecord` — Compiled route definition
130
- - `NavigateOptions` — Options for navigation
131
- - `ZenLinkProps` — Props for ZenLink component
7
+ - [Routing Contract](../../docs/documentation/contracts/routing.md)
8
+ - [Navigation Contract](../../docs/documentation/contracts/navigation.md)
9
+ - [Router Contract](../../docs/documentation/contracts/router-contract.md)
10
+ - [Navigation Lifecycle Contract](../../docs/documentation/contracts/navigation-lifecycle.md)
132
11
 
133
- ## License
12
+ ## Phase 2 Runtime Summary
134
13
 
135
- MIT
136
- # zenith-router
14
+ - Plain anchors hard navigate by default.
15
+ - Soft navigation is opt-in only through `a[data-zen-link]`.
16
+ - `ZenLink` is a thin anchor wrapper over the same marker contract.
17
+ - Soft navigation fetches fresh same-origin HTML before committing history.
18
+ - Redirects, denies, unmatched routes, non-HTML responses, and runtime failures fall back to browser navigation.
19
+ - Client routing mirrors server route precedence and pathname-based identity.
20
+ - Phase 2 adds awaited lifecycle barriers at `navigation:before-leave`, `navigation:before-swap`, and `navigation:before-enter`.
package/dist/ZenLink.zen CHANGED
@@ -1,7 +1,70 @@
1
- <script setup="ts">
2
- export const props = { href: "", class: "", target: "", rel: "" };
1
+ <script lang="ts">
2
+ export interface Props {
3
+ href?: string;
4
+ class?: string;
5
+ target?: string;
6
+ rel?: string;
7
+ id?: string;
8
+ title?: string;
9
+ ariaLabel?: string;
10
+ ariaCurrent?: string;
11
+ ariaDisabled?: string;
12
+ elementRef?: any;
13
+ onClick?: (event: MouseEvent) => void;
14
+ onHoverIn?: (event: PointerEvent) => void;
15
+ onHoverOut?: (event: PointerEvent) => void;
16
+ onFocus?: (event: FocusEvent) => void;
17
+ onBlur?: (event: FocusEvent) => void;
18
+ }
19
+
20
+ const zenLinkProps = props as Props;
21
+ const zenLinkHref = typeof zenLinkProps.href === "string" ? zenLinkProps.href : "";
22
+ const zenLinkClass = typeof zenLinkProps.class === "string" ? zenLinkProps.class : "";
23
+ const zenLinkTarget = typeof zenLinkProps.target === "string" ? zenLinkProps.target : "";
24
+ const zenLinkRel = typeof zenLinkProps.rel === "string" ? zenLinkProps.rel : "";
25
+ const zenLinkId = typeof zenLinkProps.id === "string" ? zenLinkProps.id : "";
26
+ const zenLinkTitle = typeof zenLinkProps.title === "string" ? zenLinkProps.title : "";
27
+ const zenLinkAriaLabel = typeof zenLinkProps.ariaLabel === "string" ? zenLinkProps.ariaLabel : "";
28
+ const zenLinkAriaCurrent = typeof zenLinkProps.ariaCurrent === "string" ? zenLinkProps.ariaCurrent : "";
29
+ const zenLinkAriaDisabled = typeof zenLinkProps.ariaDisabled === "string" ? zenLinkProps.ariaDisabled : "";
30
+ const zenLinkElementRef = zenLinkProps.elementRef;
31
+ const zenLinkRef = ref<HTMLAnchorElement>();
32
+ const zenLinkClick = typeof zenLinkProps.onClick === "function" ? zenLinkProps.onClick : undefined;
33
+ const zenLinkHoverIn = typeof zenLinkProps.onHoverIn === "function" ? zenLinkProps.onHoverIn : undefined;
34
+ const zenLinkHoverOut = typeof zenLinkProps.onHoverOut === "function" ? zenLinkProps.onHoverOut : undefined;
35
+ const zenLinkFocus = typeof zenLinkProps.onFocus === "function" ? zenLinkProps.onFocus : undefined;
36
+ const zenLinkBlur = typeof zenLinkProps.onBlur === "function" ? zenLinkProps.onBlur : undefined;
37
+
38
+ zenMount((ctx) => {
39
+ if (zenLinkElementRef) {
40
+ zenLinkElementRef.current = zenLinkRef.current;
41
+ }
42
+
43
+ ctx.cleanup(() => {
44
+ if (zenLinkElementRef) {
45
+ zenLinkElementRef.current = null;
46
+ }
47
+ });
48
+ });
3
49
  </script>
4
50
 
5
- <a data-zen-link="true" href={props.href} class={props.class} target={props.target} rel={props.rel}>
51
+ <a
52
+ ref={zenLinkRef}
53
+ data-zen-link="true"
54
+ href={zenLinkHref || undefined}
55
+ class={zenLinkClass || undefined}
56
+ target={zenLinkTarget || undefined}
57
+ rel={zenLinkRel || undefined}
58
+ id={zenLinkId || undefined}
59
+ title={zenLinkTitle || undefined}
60
+ aria-label={zenLinkAriaLabel || undefined}
61
+ aria-current={zenLinkAriaCurrent || undefined}
62
+ aria-disabled={zenLinkAriaDisabled || undefined}
63
+ on:click={zenLinkClick}
64
+ on:hoverin={zenLinkHoverIn}
65
+ on:hoverout={zenLinkHoverOut}
66
+ on:focus={zenLinkFocus}
67
+ on:blur={zenLinkBlur}
68
+ >
6
69
  <slot></slot>
7
70
  </a>
package/dist/events.d.ts CHANGED
@@ -5,10 +5,12 @@ type RouteChangeDetail = {
5
5
  matched: boolean;
6
6
  };
7
7
  type RouteProtectionPolicy = {
8
- beforeResolve?: boolean;
9
- emitRedirects?: boolean;
8
+ onDeny?: 'stay' | 'redirect' | 'render403' | ((ctx: any) => void);
9
+ defaultLoginPath?: string;
10
+ deny401RedirectToLogin?: boolean;
11
+ forbiddenPath?: string;
10
12
  };
11
- type RouteEventHandler = (payload: unknown) => void;
13
+ type RouteEventHandler = (payload: unknown) => void | Promise<void>;
12
14
  export declare function onRouteChange(callback: (detail: RouteChangeDetail) => void): () => void;
13
15
  export declare function _dispatchRouteChange(detail: RouteChangeDetail): void;
14
16
  export declare function _clearSubscribers(): void;
@@ -18,4 +20,6 @@ export declare function _getRouteProtectionPolicy(): RouteProtectionPolicy;
18
20
  export declare function on(eventName: string, handler: RouteEventHandler): void;
19
21
  export declare function off(eventName: string, handler: RouteEventHandler): void;
20
22
  export declare function _dispatchRouteEvent(eventName: string, payload: unknown): void;
23
+ export declare function _dispatchRouteEventAsync(eventName: string, payload: unknown): Promise<void>;
24
+ export declare function _clearRouteEventListeners(): void;
21
25
  export {};
package/dist/events.js CHANGED
@@ -34,7 +34,17 @@ const ROUTE_EVENT_NAMES = [
34
34
  'route-check:end',
35
35
  'route-check:error',
36
36
  'route:deny',
37
- 'route:redirect'
37
+ 'route:redirect',
38
+ 'navigation:request',
39
+ 'navigation:before-leave',
40
+ 'navigation:leave-complete',
41
+ 'navigation:data-ready',
42
+ 'navigation:before-swap',
43
+ 'navigation:content-swapped',
44
+ 'navigation:before-enter',
45
+ 'navigation:enter-complete',
46
+ 'navigation:abort',
47
+ 'navigation:error'
38
48
  ];
39
49
  function getRouteProtectionScope() {
40
50
  return typeof globalThis === 'object' && globalThis
@@ -80,6 +90,27 @@ export function off(eventName, handler) {
80
90
  eventListeners.delete(handler);
81
91
  }
82
92
  }
93
+ function dispatchRouteEventError(eventName, payload, error) {
94
+ console.error(`[Zenith Router] Error in ${eventName} listener:`, error);
95
+ if (eventName === 'navigation:error' ||
96
+ !payload ||
97
+ typeof payload !== 'object' ||
98
+ typeof payload.navigationId !== 'number') {
99
+ return;
100
+ }
101
+ _dispatchRouteEvent('navigation:error', {
102
+ navigationId: payload.navigationId,
103
+ navigationType: payload.navigationType,
104
+ to: payload.to,
105
+ from: payload.from,
106
+ routeId: payload.routeId,
107
+ params: payload.params,
108
+ stage: payload.stage ?? 'listener',
109
+ reason: 'listener-error',
110
+ hook: eventName,
111
+ error
112
+ });
113
+ }
83
114
  export function _dispatchRouteEvent(eventName, payload) {
84
115
  const eventListeners = ensureRouteProtectionState().listeners[eventName];
85
116
  if (!(eventListeners instanceof Set)) {
@@ -87,10 +118,38 @@ export function _dispatchRouteEvent(eventName, payload) {
87
118
  }
88
119
  for (const handler of eventListeners) {
89
120
  try {
90
- handler(payload);
121
+ const result = handler(payload);
122
+ if (result && typeof result.catch === 'function') {
123
+ result.catch((error) => {
124
+ dispatchRouteEventError(eventName, payload, error);
125
+ });
126
+ }
127
+ }
128
+ catch (error) {
129
+ dispatchRouteEventError(eventName, payload, error);
130
+ }
131
+ }
132
+ }
133
+ export async function _dispatchRouteEventAsync(eventName, payload) {
134
+ const eventListeners = ensureRouteProtectionState().listeners[eventName];
135
+ if (!(eventListeners instanceof Set)) {
136
+ return;
137
+ }
138
+ const handlers = Array.from(eventListeners);
139
+ for (const handler of handlers) {
140
+ try {
141
+ await handler(payload);
91
142
  }
92
143
  catch (error) {
93
- console.error(`[Zenith Router] Error in ${eventName} listener:`, error);
144
+ dispatchRouteEventError(eventName, payload, error);
145
+ }
146
+ }
147
+ }
148
+ export function _clearRouteEventListeners() {
149
+ const listeners = ensureRouteProtectionState().listeners;
150
+ for (const eventName of Object.keys(listeners)) {
151
+ if (listeners[eventName] instanceof Set) {
152
+ listeners[eventName].clear();
94
153
  }
95
154
  }
96
155
  }
package/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type RouteResult =
2
2
  | { kind: "allow" }
3
3
  | { kind: "redirect"; location: string; status?: number }
4
- | { kind: "deny"; status: 401 | 403 | 500; message?: string }
4
+ | { kind: "deny"; status: 401 | 403 | 404; message?: string }
5
5
  | { kind: "data"; data: any };
6
6
 
7
7
  export type GuardResult = Extract<RouteResult, { kind: "allow" | "redirect" | "deny" }>;
@@ -22,7 +22,7 @@ export interface RouteContext {
22
22
  };
23
23
  allow(): { kind: "allow" };
24
24
  redirect(location: string, status?: number): { kind: "redirect"; location: string; status: number };
25
- deny(status: 401 | 403 | 500, message?: string): { kind: "deny"; status: 401 | 403 | 500; message?: string };
25
+ deny(status: 401 | 403 | 404, message?: string): { kind: "deny"; status: 401 | 403 | 404; message?: string };
26
26
  data(payload: any): { kind: "data"; data: any };
27
27
  }
28
28
 
@@ -41,6 +41,38 @@ export interface RouteProtectionPolicy {
41
41
  forbiddenPath?: string;
42
42
  }
43
43
 
44
+ export type NavigationType = "push" | "pop";
45
+
46
+ export interface NavigationLifecyclePayload {
47
+ navigationId: number;
48
+ navigationType: NavigationType;
49
+ to: URL | null;
50
+ from: URL | null;
51
+ routeId: string;
52
+ params: Record<string, string>;
53
+ stage: string;
54
+ document?: {
55
+ title: string;
56
+ hasSsrData: boolean;
57
+ status: number;
58
+ };
59
+ scroll?: {
60
+ mode: "top" | "restore" | "hash";
61
+ x: number;
62
+ y: number;
63
+ hash: string;
64
+ };
65
+ reason?: string;
66
+ hook?: string;
67
+ location?: string;
68
+ status?: number;
69
+ historyCommitted?: boolean;
70
+ error?: unknown;
71
+ [key: string]: unknown;
72
+ }
73
+
74
+ export type RouteEventHandler = (payload: unknown) => void | Promise<void>;
75
+
44
76
  export type RouteEventName =
45
77
  | "guard:start"
46
78
  | "guard:end"
@@ -48,8 +80,18 @@ export type RouteEventName =
48
80
  | "route-check:end"
49
81
  | "route-check:error"
50
82
  | "route:deny"
51
- | "route:redirect";
83
+ | "route:redirect"
84
+ | "navigation:request"
85
+ | "navigation:before-leave"
86
+ | "navigation:leave-complete"
87
+ | "navigation:data-ready"
88
+ | "navigation:before-swap"
89
+ | "navigation:content-swapped"
90
+ | "navigation:before-enter"
91
+ | "navigation:enter-complete"
92
+ | "navigation:abort"
93
+ | "navigation:error";
52
94
 
53
95
  export declare function setRouteProtectionPolicy(policy: RouteProtectionPolicy): void;
54
- export declare function on(eventName: RouteEventName, handler: (payload: any) => void): void;
55
- export declare function off(eventName: RouteEventName, handler: (payload: any) => void): void;
96
+ export declare function on(eventName: RouteEventName, handler: RouteEventHandler): void;
97
+ export declare function off(eventName: RouteEventName, handler: RouteEventHandler): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/router",
3
- "version": "0.6.17",
3
+ "version": "0.7.1",
4
4
  "description": "File-based SPA router for Zenith framework with deterministic, compile-time route resolution",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,6 +8,9 @@
8
8
  "files": [
9
9
  "dist",
10
10
  "template.js",
11
+ "template-core.js",
12
+ "template-lifecycle.js",
13
+ "template-navigation.js",
11
14
  "index.js",
12
15
  "index.d.ts",
13
16
  "README.md",
@@ -18,6 +21,7 @@
18
21
  "exports": {
19
22
  ".": "./dist/index.js",
20
23
  "./template": "./template.js",
24
+ "./events": "./dist/events.js",
21
25
  "./ZenLink.zen": "./dist/ZenLink.zen"
22
26
  },
23
27
  "keywords": [