@ecopages/react-router 0.2.0-alpha.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/browser.d.ts +13 -0
- package/browser.js +11 -0
- package/browser.ts +17 -0
- package/package.json +42 -0
- package/src/adapter.d.ts +28 -0
- package/src/adapter.js +22 -0
- package/src/adapter.ts +48 -0
- package/src/context.d.ts +16 -0
- package/src/context.js +11 -0
- package/src/context.ts +25 -0
- package/src/head-morpher.d.ts +15 -0
- package/src/head-morpher.js +94 -0
- package/src/head-morpher.ts +170 -0
- package/src/index.d.ts +14 -0
- package/src/index.js +13 -0
- package/src/index.ts +21 -0
- package/src/manage-scroll.d.ts +17 -0
- package/src/manage-scroll.js +25 -0
- package/src/manage-scroll.ts +47 -0
- package/src/navigation.d.ts +65 -0
- package/src/navigation.js +120 -0
- package/src/navigation.ts +247 -0
- package/src/props-script.d.ts +11 -0
- package/src/props-script.js +11 -0
- package/src/props-script.ts +19 -0
- package/src/router.d.ts +73 -0
- package/src/router.js +225 -0
- package/src/router.ts +348 -0
- package/src/scroll-persist.d.ts +40 -0
- package/src/scroll-persist.js +57 -0
- package/src/scroll-persist.ts +96 -0
- package/src/styles.css +200 -0
- package/src/types.d.ts +49 -0
- package/src/types.js +12 -0
- package/src/types.ts +64 -0
- package/src/view-transition-manager.d.ts +5 -0
- package/src/view-transition-manager.js +16 -0
- package/src/view-transition-manager.ts +30 -0
- package/src/view-transition-utils.d.ts +13 -0
- package/src/view-transition-utils.js +60 -0
- package/src/view-transition-utils.ts +95 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@ecopages/react-router` are documented here.
|
|
4
|
+
|
|
5
|
+
> **Note:** Changelog tracking begins at version `0.2.0`. Changes prior to this release are not recorded here but are available in the git history.
|
|
6
|
+
|
|
7
|
+
## [UNRELEASED] — TBD
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- Published npm package metadata now includes validated declaration exports for generated dist entrypoints.
|
|
12
|
+
|
|
13
|
+
### Refactoring
|
|
14
|
+
|
|
15
|
+
- Updated `package.json` dependencies to align with the new core adapter and esbuild build adapter versions.
|
|
16
|
+
- Internal peer dependency declarations updated for React 18+ and the new `@ecopages/core` API surface.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Andrea Zanenghi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# @ecopages/react-router
|
|
2
|
+
|
|
3
|
+
Client-side SPA router for EcoPages React applications. Enables single-page application navigation while preserving full SSR benefits.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @ecopages/react-router
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
Add the router adapter to your `eco.config.ts`:
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { ConfigBuilder } from '@ecopages/core';
|
|
17
|
+
import { reactPlugin } from '@ecopages/react';
|
|
18
|
+
import { ecoRouter } from '@ecopages/react-router';
|
|
19
|
+
|
|
20
|
+
const config = await new ConfigBuilder()
|
|
21
|
+
.setRootDir(import.meta.dir)
|
|
22
|
+
.setIntegrations([reactPlugin({ router: ecoRouter() })])
|
|
23
|
+
.build();
|
|
24
|
+
|
|
25
|
+
export default config;
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That's it! All pages now have SPA navigation enabled.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Opt-in via config** - Single line enables SPA for all pages
|
|
33
|
+
- **SSR preserved** - Full server-side rendering on initial load
|
|
34
|
+
- **Layout persistence** - Layouts stay mounted, only page content swaps
|
|
35
|
+
- **Standard links** - Works with regular `<a>` tags
|
|
36
|
+
- **Head sync** - Automatically updates title, meta, and stylesheets
|
|
37
|
+
- **Pluggable** - Extensible adapter pattern
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Layouts (Optional)
|
|
42
|
+
|
|
43
|
+
Use `config.layout` for persistent UI across navigations:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
// src/layouts/base-layout.tsx
|
|
47
|
+
export const BaseLayout = ({ children }) => (
|
|
48
|
+
<html>
|
|
49
|
+
<body>
|
|
50
|
+
<header>My Site</header>
|
|
51
|
+
<main>{children}</main>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// src/pages/index.tsx
|
|
57
|
+
import { BaseLayout } from '../layouts/base-layout';
|
|
58
|
+
|
|
59
|
+
const HomePage = () => <h1>Welcome</h1>;
|
|
60
|
+
|
|
61
|
+
HomePage.config = { layout: BaseLayout };
|
|
62
|
+
|
|
63
|
+
export default HomePage;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Links
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
// SPA navigation (intercepted)
|
|
70
|
+
<a href="/about">About</a>
|
|
71
|
+
|
|
72
|
+
// Force full reload
|
|
73
|
+
<a href="/external" data-eco-reload>External</a>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Programmatic Navigation
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import { useRouter } from '@ecopages/react-router';
|
|
80
|
+
|
|
81
|
+
const MyComponent = () => {
|
|
82
|
+
const { navigate, isPending } = useRouter();
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<button onClick={() => navigate('/about')} disabled={isPending}>
|
|
86
|
+
Go to About
|
|
87
|
+
</button>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### View Transitions
|
|
93
|
+
|
|
94
|
+
The router automatically supports the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for smooth page transitions.
|
|
95
|
+
|
|
96
|
+
#### Lifecycle
|
|
97
|
+
|
|
98
|
+
When a navigation occurs with View Transitions enabled:
|
|
99
|
+
|
|
100
|
+
1. **Snapshot**: The browser captures the current state (screenshot) of the page.
|
|
101
|
+
2. **Update**: React processes the state change and renders the new page.
|
|
102
|
+
3. **Animate**: The browser animates from the old snapshot to the new live state.
|
|
103
|
+
|
|
104
|
+
The router uses a deferred promise mechanism to ensure React has fully finished rendering the new content before telling the browser to start the animation phase.
|
|
105
|
+
|
|
106
|
+
#### Shared Element Transitions
|
|
107
|
+
|
|
108
|
+
To animate elements between pages (e.g., a thumbnail becoming a hero image), use the `data-view-transition` attribute. Ensure the value is unique to the specific element being transitioned and matches on both pages.
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
// List Page (Source)
|
|
112
|
+
<img
|
|
113
|
+
src={post.image}
|
|
114
|
+
data-view-transition={`hero-${post.id}`}
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
// Detail Page (Destination)
|
|
118
|
+
<img
|
|
119
|
+
src={post.image}
|
|
120
|
+
data-view-transition={`hero-${post.id}`}
|
|
121
|
+
/>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
By default, the router applies a **clean morph** animation (disabling the default cross-fade ghosting). If you prefer the standard browser cross-fade, you can opt-out:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
<div data-view-transition="my-hero" data-view-transition-animate="fade">
|
|
128
|
+
...
|
|
129
|
+
</div>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Cross-Fade
|
|
133
|
+
|
|
134
|
+
By default, the router provides a smooth cross-fade for the root content. You can customize this by overriding the default view transition CSS:
|
|
135
|
+
|
|
136
|
+
```css
|
|
137
|
+
::view-transition-old(root),
|
|
138
|
+
::view-transition-new(root) {
|
|
139
|
+
animation-duration: 0.5s;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## How It Works
|
|
144
|
+
|
|
145
|
+
The router uses an **HTML-First** navigation strategy to ensure consistency with Server-Side Rendering (SSR).
|
|
146
|
+
|
|
147
|
+
1. **SSR**: Server renders full HTML for the initial page load.
|
|
148
|
+
2. **Hydration**: Client hydrates, router attaches to the document.
|
|
149
|
+
3. **Navigation**: On link click:
|
|
150
|
+
- **Fetch**: Requests the full HTML of the target page (just like a standard browser navigation).
|
|
151
|
+
- **Parse**: Extracts the page component URL and serialized props from the HTML.
|
|
152
|
+
- **Preload**: Dynamically imports the new page component.
|
|
153
|
+
- **Transition**:
|
|
154
|
+
- Calls `document.startViewTransition()`.
|
|
155
|
+
- Updates the document head (title, meta, styles).
|
|
156
|
+
- Updates the React state to render the new page component.
|
|
157
|
+
- Waits for React commit (useEffect).
|
|
158
|
+
- **Resolve**: View Transition finishes, browser plays the animation.
|
|
159
|
+
|
|
160
|
+
## API
|
|
161
|
+
|
|
162
|
+
### `ecoRouter()`
|
|
163
|
+
|
|
164
|
+
Creates a router adapter for the React plugin.
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
reactPlugin({ router: ecoRouter() });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### `useRouter()`
|
|
171
|
+
|
|
172
|
+
Hook for programmatic navigation.
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
const { navigate, isPending } = useRouter();
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Link Behavior
|
|
179
|
+
|
|
180
|
+
Links are **not** intercepted when:
|
|
181
|
+
|
|
182
|
+
- Modifier keys held (Ctrl, Cmd, Shift, Alt)
|
|
183
|
+
- Has `target="_blank"` or `download` attribute
|
|
184
|
+
- Has `data-eco-reload` attribute
|
|
185
|
+
- Points to different origin
|
|
186
|
+
- Starts with `#` or `javascript:`
|
|
187
|
+
|
|
188
|
+
## Architecture
|
|
189
|
+
|
|
190
|
+
The router uses a pluggable adapter pattern:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
interface ReactRouterAdapter {
|
|
194
|
+
name: string;
|
|
195
|
+
bundle: { importPath; outputName; externals };
|
|
196
|
+
importMapKey: string;
|
|
197
|
+
components: { router; pageContent };
|
|
198
|
+
getRouterProps(page, props): string;
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
This allows custom router implementations while keeping integration simple.
|
|
203
|
+
|
|
204
|
+
## Compatibility
|
|
205
|
+
|
|
206
|
+
- React 18.x or 19.x
|
|
207
|
+
- Modern browsers with ES modules
|
|
208
|
+
- EcoPages with React integration
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT
|
package/browser.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry point for @ecopages/react-router.
|
|
3
|
+
* This file exports only the client-side components needed for hydration.
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
export { EcoRouter, PageContent } from './src/router.js';
|
|
7
|
+
export type { EcoRouterProps } from './src/router.js';
|
|
8
|
+
export { useRouter } from './src/context.js';
|
|
9
|
+
export type { RouterContextValue } from './src/context.js';
|
|
10
|
+
export { EcoPropsScript } from './src/props-script.js';
|
|
11
|
+
export type { EcoPropsScriptProps } from './src/props-script.js';
|
|
12
|
+
export { morphHead } from './src/head-morpher.js';
|
|
13
|
+
export type { PageState } from './src/navigation.js';
|
package/browser.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { EcoRouter, PageContent } from "./src/router.js";
|
|
2
|
+
import { useRouter } from "./src/context.js";
|
|
3
|
+
import { EcoPropsScript } from "./src/props-script.js";
|
|
4
|
+
import { morphHead } from "./src/head-morpher.js";
|
|
5
|
+
export {
|
|
6
|
+
EcoPropsScript,
|
|
7
|
+
EcoRouter,
|
|
8
|
+
PageContent,
|
|
9
|
+
morphHead,
|
|
10
|
+
useRouter
|
|
11
|
+
};
|
package/browser.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry point for @ecopages/react-router.
|
|
3
|
+
* This file exports only the client-side components needed for hydration.
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { EcoRouter, PageContent } from './src/router.ts';
|
|
8
|
+
export type { EcoRouterProps } from './src/router.ts';
|
|
9
|
+
|
|
10
|
+
export { useRouter } from './src/context.ts';
|
|
11
|
+
export type { RouterContextValue } from './src/context.ts';
|
|
12
|
+
export { EcoPropsScript } from './src/props-script.ts';
|
|
13
|
+
export type { EcoPropsScriptProps } from './src/props-script.ts';
|
|
14
|
+
|
|
15
|
+
export { morphHead } from './src/head-morpher.ts';
|
|
16
|
+
|
|
17
|
+
export type { PageState } from './src/navigation.ts';
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ecopages/react-router",
|
|
3
|
+
"version": "0.2.0-alpha.1",
|
|
4
|
+
"description": "Client-side SPA router for EcoPages React applications",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ecopages",
|
|
7
|
+
"react",
|
|
8
|
+
"router",
|
|
9
|
+
"spa",
|
|
10
|
+
"navigation",
|
|
11
|
+
"ssr"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./src/index.d.ts",
|
|
18
|
+
"default": "./src/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./browser": {
|
|
21
|
+
"types": "./browser.d.ts",
|
|
22
|
+
"default": "./browser.js"
|
|
23
|
+
},
|
|
24
|
+
"./browser.ts": {
|
|
25
|
+
"types": "./browser.d.ts",
|
|
26
|
+
"default": "./browser.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/ecopages/ecopages.git",
|
|
32
|
+
"directory": "packages/react-router"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@ecopages/react": "0.2.0-alpha.1"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"react": "^19",
|
|
39
|
+
"react-dom": "^19.2.4"
|
|
40
|
+
},
|
|
41
|
+
"types": "./src/index.d.ts"
|
|
42
|
+
}
|
package/src/adapter.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router adapter for React integration.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
import type { ReactRouterAdapter } from '@ecopages/react/router-adapter';
|
|
6
|
+
import type { EcoRouterOptions } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Creates a ReactRouterAdapter for EcoPages React Router.
|
|
9
|
+
* Use this with the React plugin to enable SPA navigation.
|
|
10
|
+
*
|
|
11
|
+
* @param options - Router configuration options
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { reactPlugin } from '@ecopages/react';
|
|
15
|
+
* import { ecoRouter } from '@ecopages/react-router';
|
|
16
|
+
*
|
|
17
|
+
* export default {
|
|
18
|
+
* integrations: [reactPlugin({ router: ecoRouter() })],
|
|
19
|
+
* };
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* // Disable view transitions
|
|
25
|
+
* reactPlugin({ router: ecoRouter({ viewTransitions: false }) })
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function ecoRouter(options?: EcoRouterOptions): ReactRouterAdapter;
|
package/src/adapter.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function ecoRouter(options) {
|
|
2
|
+
return {
|
|
3
|
+
name: "eco-router",
|
|
4
|
+
bundle: {
|
|
5
|
+
importPath: "@ecopages/react-router/browser.ts",
|
|
6
|
+
outputName: "react-router-esm",
|
|
7
|
+
externals: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"]
|
|
8
|
+
},
|
|
9
|
+
importMapKey: "@ecopages/react-router",
|
|
10
|
+
components: {
|
|
11
|
+
router: "EcoRouter",
|
|
12
|
+
pageContent: "PageContent"
|
|
13
|
+
},
|
|
14
|
+
getRouterProps(page, props) {
|
|
15
|
+
const optionsStr = options ? `, options: ${JSON.stringify(options)}` : "";
|
|
16
|
+
return `{ page: ${page}, pageProps: ${props}${optionsStr} }`;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
ecoRouter
|
|
22
|
+
};
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router adapter for React integration.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ReactRouterAdapter } from '@ecopages/react/router-adapter';
|
|
7
|
+
import type { EcoRouterOptions } from './types.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates a ReactRouterAdapter for EcoPages React Router.
|
|
11
|
+
* Use this with the React plugin to enable SPA navigation.
|
|
12
|
+
*
|
|
13
|
+
* @param options - Router configuration options
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { reactPlugin } from '@ecopages/react';
|
|
17
|
+
* import { ecoRouter } from '@ecopages/react-router';
|
|
18
|
+
*
|
|
19
|
+
* export default {
|
|
20
|
+
* integrations: [reactPlugin({ router: ecoRouter() })],
|
|
21
|
+
* };
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* // Disable view transitions
|
|
27
|
+
* reactPlugin({ router: ecoRouter({ viewTransitions: false }) })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function ecoRouter(options?: EcoRouterOptions): ReactRouterAdapter {
|
|
31
|
+
return {
|
|
32
|
+
name: 'eco-router',
|
|
33
|
+
bundle: {
|
|
34
|
+
importPath: '@ecopages/react-router/browser.ts',
|
|
35
|
+
outputName: 'react-router-esm',
|
|
36
|
+
externals: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
|
|
37
|
+
},
|
|
38
|
+
importMapKey: '@ecopages/react-router',
|
|
39
|
+
components: {
|
|
40
|
+
router: 'EcoRouter',
|
|
41
|
+
pageContent: 'PageContent',
|
|
42
|
+
},
|
|
43
|
+
getRouterProps(page: string, props: string): string {
|
|
44
|
+
const optionsStr = options ? `, options: ${JSON.stringify(options)}` : '';
|
|
45
|
+
return `{ page: ${page}, pageProps: ${props}${optionsStr} }`;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/context.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router context and hook for accessing navigation state.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
export type RouterContextValue = {
|
|
6
|
+
navigate: (url: string) => void;
|
|
7
|
+
isNavigating: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare const RouterContext: import("react").Context<RouterContextValue | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Hook to access the router's navigate function and navigation state.
|
|
12
|
+
* Must be used within an EcoRouter.
|
|
13
|
+
*
|
|
14
|
+
* @throws Error if used outside of EcoRouter
|
|
15
|
+
*/
|
|
16
|
+
export declare const useRouter: () => RouterContextValue;
|
package/src/context.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
const RouterContext = createContext(null);
|
|
3
|
+
const useRouter = () => {
|
|
4
|
+
const context = useContext(RouterContext);
|
|
5
|
+
if (!context) throw new Error("useRouter must be used within EcoRouter");
|
|
6
|
+
return context;
|
|
7
|
+
};
|
|
8
|
+
export {
|
|
9
|
+
RouterContext,
|
|
10
|
+
useRouter
|
|
11
|
+
};
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router context and hook for accessing navigation state.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createContext, useContext } from 'react';
|
|
7
|
+
|
|
8
|
+
export type RouterContextValue = {
|
|
9
|
+
navigate: (url: string) => void;
|
|
10
|
+
isNavigating: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const RouterContext = createContext<RouterContextValue | null>(null);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hook to access the router's navigate function and navigation state.
|
|
17
|
+
* Must be used within an EcoRouter.
|
|
18
|
+
*
|
|
19
|
+
* @throws Error if used outside of EcoRouter
|
|
20
|
+
*/
|
|
21
|
+
export const useRouter = (): RouterContextValue => {
|
|
22
|
+
const context = useContext(RouterContext);
|
|
23
|
+
if (!context) throw new Error('useRouter must be used within EcoRouter');
|
|
24
|
+
return context;
|
|
25
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Head morphing utilities for client-side navigation.
|
|
3
|
+
* Intelligently syncs head elements between pages using key-based diffing.
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Morphs the current document head to match the new document's head.
|
|
8
|
+
* Now splits the process into adding new elements and returning a cleanup function
|
|
9
|
+
* to remove old ones. This is crucial for View Transitions to ensure styles
|
|
10
|
+
* don't disappear before the "old" snapshot is taken.
|
|
11
|
+
*
|
|
12
|
+
* @param newDocument - The parsed document from the navigation target
|
|
13
|
+
* @returns Promise that resolves to a cleanup function when new stylesheets have loaded
|
|
14
|
+
*/
|
|
15
|
+
export declare function morphHead(newDocument: Document): Promise<() => void>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const PRESERVE_SELECTORS = ['script[type="importmap"]', "meta[charset]", "[data-eco-persist]"];
|
|
2
|
+
function getHeadElementKey(el) {
|
|
3
|
+
const tag = el.tagName.toLowerCase();
|
|
4
|
+
switch (tag) {
|
|
5
|
+
case "title":
|
|
6
|
+
return "title";
|
|
7
|
+
case "meta": {
|
|
8
|
+
const name = el.getAttribute("name") || el.getAttribute("property") || el.getAttribute("http-equiv");
|
|
9
|
+
return name ? `meta:${name}` : null;
|
|
10
|
+
}
|
|
11
|
+
case "link": {
|
|
12
|
+
const rel = el.getAttribute("rel");
|
|
13
|
+
const href = el.getAttribute("href");
|
|
14
|
+
if (rel === "stylesheet" && href) return `stylesheet:${href}`;
|
|
15
|
+
if (rel === "icon" || rel === "shortcut icon") return "favicon";
|
|
16
|
+
if (rel === "canonical") return "canonical";
|
|
17
|
+
return href ? `link:${href}` : null;
|
|
18
|
+
}
|
|
19
|
+
case "script": {
|
|
20
|
+
if (el.getAttribute("type") === "importmap") return "importmap";
|
|
21
|
+
const src = el.src;
|
|
22
|
+
return src ? `script:${src}` : null;
|
|
23
|
+
}
|
|
24
|
+
case "style": {
|
|
25
|
+
const dataId = el.getAttribute("data-eco-style");
|
|
26
|
+
return dataId ? `style:${dataId}` : null;
|
|
27
|
+
}
|
|
28
|
+
default:
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function morphHead(newDocument) {
|
|
33
|
+
const currentHead = document.head;
|
|
34
|
+
const newHead = newDocument.head;
|
|
35
|
+
const currentElements = /* @__PURE__ */ new Map();
|
|
36
|
+
const newElements = /* @__PURE__ */ new Map();
|
|
37
|
+
const stylesheetPromises = [];
|
|
38
|
+
const elementsToRemove = [];
|
|
39
|
+
for (const el of Array.from(currentHead.children)) {
|
|
40
|
+
const key = getHeadElementKey(el);
|
|
41
|
+
if (key) currentElements.set(key, el);
|
|
42
|
+
}
|
|
43
|
+
for (const el of Array.from(newHead.children)) {
|
|
44
|
+
const key = getHeadElementKey(el);
|
|
45
|
+
if (key) newElements.set(key, el);
|
|
46
|
+
}
|
|
47
|
+
for (const [key, newEl] of newElements) {
|
|
48
|
+
const currentEl = currentElements.get(key);
|
|
49
|
+
if (!currentEl) {
|
|
50
|
+
const src = newEl.getAttribute("src");
|
|
51
|
+
if (newEl.tagName === "SCRIPT" && src && src.includes("hydration.js") && src.includes("ecopages-react")) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const cloned = newEl.cloneNode(true);
|
|
55
|
+
if (cloned.tagName === "LINK" && cloned.rel === "stylesheet") {
|
|
56
|
+
const loadPromise = new Promise((resolve) => {
|
|
57
|
+
cloned.onload = () => resolve();
|
|
58
|
+
cloned.onerror = () => resolve();
|
|
59
|
+
});
|
|
60
|
+
stylesheetPromises.push(loadPromise);
|
|
61
|
+
}
|
|
62
|
+
currentHead.appendChild(cloned);
|
|
63
|
+
} else if (key === "title" && currentEl.textContent !== newEl.textContent) {
|
|
64
|
+
currentEl.textContent = newEl.textContent;
|
|
65
|
+
} else if (key.startsWith("style:") && currentEl.textContent !== newEl.textContent) {
|
|
66
|
+
currentEl.textContent = newEl.textContent;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const newEl of Array.from(newHead.children)) {
|
|
70
|
+
const key = getHeadElementKey(newEl);
|
|
71
|
+
if (!key) {
|
|
72
|
+
currentHead.appendChild(newEl.cloneNode(true));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (stylesheetPromises.length > 0) {
|
|
76
|
+
await Promise.all(stylesheetPromises);
|
|
77
|
+
}
|
|
78
|
+
for (const [key, el] of currentElements) {
|
|
79
|
+
if (!newElements.has(key)) {
|
|
80
|
+
const shouldPreserve = PRESERVE_SELECTORS.some((sel) => el.matches(sel));
|
|
81
|
+
if (!shouldPreserve) {
|
|
82
|
+
elementsToRemove.push(el);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return () => {
|
|
87
|
+
for (const el of elementsToRemove) {
|
|
88
|
+
el.remove();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export {
|
|
93
|
+
morphHead
|
|
94
|
+
};
|