@aminnairi/react-router 3.0.0 → 3.0.2

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/package.json CHANGED
@@ -2,11 +2,15 @@
2
2
  "type": "module",
3
3
  "name": "@aminnairi/react-router",
4
4
  "description": "Type-safe router for the React library",
5
- "version": "3.0.0",
6
- "homepage": "https://github.com/aminnairi/react-router#readme",
5
+ "version": "3.0.2",
6
+ "homepage": "https://github.com/aminnairi/react-router",
7
7
  "license": "MIT",
8
+ "types": "dist/index.d.ts",
9
+ "main": "dist/index.js",
10
+ "readme": "https://github.com/aminnairi/react-router#readme",
8
11
  "files": [
9
- "index.tsx"
12
+ "dist/index.js",
13
+ "dist/index.d.ts"
10
14
  ],
11
15
  "bugs": {
12
16
  "url": "https://github.com/aminnairi/react-router/issues"
@@ -31,13 +35,15 @@
31
35
  "react": ">=18.0.0"
32
36
  },
33
37
  "scripts": {
34
- "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
38
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
39
+ "build": "tsc && rolldown --config rolldown.config.ts"
35
40
  },
36
41
  "devDependencies": {
37
42
  "@typescript-eslint/eslint-plugin": "^7.2.0",
38
43
  "@typescript-eslint/parser": "^7.2.0",
39
44
  "eslint": "^8.57.0",
40
45
  "eslint-plugin-react-hooks": "^4.6.0",
46
+ "rolldown": "^1.0.0-rc.9",
41
47
  "typescript": "^5.9.3"
42
48
  }
43
49
  }
package/index.tsx DELETED
@@ -1,527 +0,0 @@
1
- import { Component, createContext, type Dispatch, type FunctionComponent, type ReactNode, type SetStateAction, useCallback, useContext, useEffect, useEffectEvent, useMemo, useState } from "react";
2
-
3
- export interface IssueProps {
4
- error: Error | null,
5
- resetError: () => void
6
- }
7
-
8
- export interface Router<Path extends string, Locale> {
9
- prefix?: string
10
- locales?: Locale[]
11
- transition?: Transition
12
- fallback: FunctionComponent
13
- issue: FunctionComponent<IssueProps>
14
- pages: Array<Page<Path>>
15
- }
16
-
17
- export interface RouterProviderProps {
18
- children: ReactNode
19
- }
20
-
21
- export interface RouterContextInterface<Locale> {
22
- locale: Locale | null
23
- prefix: string | null
24
- path: string
25
- setLocale: Dispatch<SetStateAction<Locale | null>>
26
- }
27
-
28
- export type NavigationDirection = "forward" | "backward";
29
-
30
- function normalize(uri: string) {
31
- return uri
32
- .trim()
33
- .toLowerCase()
34
- .replace(/\/+/g, "/")
35
- .replace(/^\/+|\/$/g, "");
36
- }
37
-
38
- function matchPath(path: string, pathname: string) {
39
- const pathParts = normalize(path).split("/").filter(Boolean);
40
- const pathnameParts = normalize(pathname).split("/").filter(Boolean);
41
-
42
- return pathParts.length === pathnameParts.length && pathParts.every((pathPart, index) => {
43
- return pathPart.startsWith(":") || pathPart === pathnameParts.at(index);
44
- });
45
- }
46
-
47
- function matchParameters(path: string, pathname: string): Record<string, string> {
48
- const pathParts = normalize(path).split("/").filter(Boolean);
49
- const pathnameParts = normalize(pathname).split("/").filter(Boolean);
50
-
51
- if (pathParts.length !== pathnameParts.length) {
52
- return {};
53
- }
54
-
55
- return pathParts.reduce((parameters, pathPart, index) => {
56
- if (!pathPart.startsWith(":")) {
57
- return parameters;
58
- }
59
-
60
- return {
61
- ...parameters,
62
- [pathPart.slice(1)]: pathnameParts.at(index) ?? ""
63
- }
64
- }, {});
65
- }
66
-
67
- export class Uri<Locale> {
68
- private constructor(public readonly path: string, public readonly prefix: string | null, public readonly locale: Locale | null) { }
69
-
70
- public static from<Locale>(uri: string, expectedPrefix?: string, expectedLocales?: Locale[]): Uri<Locale> {
71
- const [prefixOrLocale, localeOrNothing, ...parts] = normalize(uri).split("/");
72
- const locales = expectedLocales ?? [];
73
-
74
- if (expectedPrefix && prefixOrLocale && prefixOrLocale === normalize(expectedPrefix)) {
75
- const locale = locales.find(expectedLocale => expectedLocale === localeOrNothing);
76
-
77
- if (locale) {
78
- return new Uri<Locale>(
79
- parts.join("/"),
80
- prefixOrLocale,
81
- locale,
82
- );
83
- }
84
-
85
- return new Uri<Locale>(
86
- [localeOrNothing, ...parts].join("/"),
87
- prefixOrLocale,
88
- null
89
- );
90
- }
91
-
92
- const locale = locales.find(expectedLocale => expectedLocale === prefixOrLocale);
93
-
94
- if (locale) {
95
- return new Uri<Locale>(
96
- [localeOrNothing, ...parts].join("/"),
97
- null,
98
- locale
99
- );
100
- }
101
-
102
- return new Uri<Locale>(
103
- [prefixOrLocale, localeOrNothing, ...parts].join("/"),
104
- null,
105
- null
106
- );
107
- }
108
- }
109
-
110
- export type ExtractParams<Path extends string> =
111
- Path extends `${string}:${infer Param}/${infer Rest}`
112
- ? Param | ExtractParams<Rest>
113
- : Path extends `${string}:${infer Param}`
114
- ? Param
115
- : never
116
-
117
- export type Params<Path extends string> = {
118
- [Key in ExtractParams<Path>]: string
119
- }
120
-
121
- export interface PageParams<Path extends string> {
122
- parameters: Params<Path>
123
- }
124
-
125
- export interface Page<Path extends string> {
126
- path: Path
127
- element: FunctionComponent<PageParams<Path>>
128
- }
129
-
130
- interface ErrorBoundaryProps {
131
- children: ReactNode;
132
- issue: FunctionComponent<IssueProps>;
133
- }
134
-
135
- class ErrorBoundary extends Component<ErrorBoundaryProps, IssueProps> {
136
- public constructor(props: ErrorBoundaryProps) {
137
- super(props);
138
-
139
- this.state = {
140
- error: null,
141
- resetError: this.resetError.bind(this)
142
- };
143
- }
144
-
145
- private resetError() {
146
- this.setState({ error: null });
147
- }
148
-
149
- public static getDerivedStateFromError(error: Error) {
150
- return {
151
- error
152
- };
153
- }
154
-
155
- public override componentDidCatch(error: unknown) {
156
- this.setState({
157
- error: error instanceof Error ? error : new Error(String(error))
158
- });
159
- }
160
-
161
- public override render() {
162
- if (this.state.error) {
163
- const Issue = this.props.issue;
164
-
165
- return <Issue error={this.state.error} resetError={this.state.resetError} />;
166
- }
167
-
168
- return this.props.children;
169
- }
170
- }
171
-
172
- export function createPage<Path extends string>(page: Page<Path>): Page<Path> {
173
- return {
174
- ...page,
175
- path: normalize(page.path) as Path
176
- };
177
- }
178
-
179
- export type Transition = (direction: "forward" | "backward", next: () => void) => void;
180
-
181
- export const scaleFadeTransition: Transition = async (direction: NavigationDirection, next) => {
182
- try {
183
- const transition = document.startViewTransition(() => {
184
- next();
185
- });
186
-
187
- await transition.ready;
188
-
189
- document.documentElement.animate(
190
- [
191
- {
192
- transform: 'scale(1)',
193
- opacity: 1
194
- },
195
- {
196
- transform: direction === "forward" ? "scale(1.04)" : "scale(0.96)",
197
- opacity: 0
198
- }
199
- ],
200
- {
201
- duration: 200,
202
- easing: "ease-in-out",
203
- fill: "both",
204
- pseudoElement: `::view-transition-old(root)`,
205
- }
206
- );
207
-
208
- document.documentElement.animate(
209
- [
210
- {
211
- transform: direction === "forward" ? "scale(0.96)" : "scale(1.04)",
212
- opacity: 0
213
- },
214
- {
215
- transform: 'scale(1)',
216
- opacity: 1
217
- }
218
- ],
219
- {
220
- duration: 200,
221
- easing: "ease-in-out",
222
- fill: "both",
223
- pseudoElement: `::view-transition-new(root)`,
224
- }
225
- );
226
- } catch (error) {
227
- console.error(error);
228
- }
229
- }
230
-
231
- export const crossFadeTransition: Transition = async (direction: NavigationDirection, next) => {
232
- try {
233
- const transition = document.startViewTransition(() => {
234
- next();
235
- });
236
-
237
- await transition.ready;
238
-
239
- document.documentElement.animate(
240
- [
241
- { opacity: 1 },
242
- { opacity: 0 }
243
- ],
244
- {
245
- duration: 200,
246
- easing: "ease-in-out",
247
- fill: "both",
248
- pseudoElement: `::view-transition-old(root)`,
249
- }
250
- );
251
-
252
- document.documentElement.animate(
253
- [
254
- { opacity: 0 },
255
- { opacity: 1 }
256
- ],
257
- {
258
- duration: 200,
259
- easing: "ease-in-out",
260
- fill: "both",
261
- pseudoElement: `::view-transition-new(root)`,
262
- }
263
- );
264
- } catch (error) {
265
- console.error(error);
266
- }
267
- }
268
-
269
- export const slideHorizontalTransition: Transition = async (direction: NavigationDirection, next) => {
270
- try {
271
- const transition = document.startViewTransition(() => {
272
- next();
273
- });
274
-
275
- await transition.ready;
276
-
277
- document.documentElement.animate(
278
- [
279
- { transform: 'translateX(0)' },
280
- { transform: direction === "forward" ? 'translateX(-100%)' : 'translateX(100%)' }
281
- ],
282
- {
283
- duration: 200,
284
- easing: "ease-in-out",
285
- fill: "both",
286
- pseudoElement: `::view-transition-old(root)`,
287
- }
288
- );
289
-
290
- document.documentElement.animate(
291
- [
292
- { transform: direction === "forward" ? 'translateX(100%)' : 'translateX(-100%)' },
293
- { transform: 'translateX(0)' }
294
- ],
295
- {
296
- duration: 200,
297
- easing: "ease-in-out",
298
- fill: "both",
299
- pseudoElement: `::view-transition-new(root)`,
300
- }
301
- );
302
- } catch (error) {
303
- console.error(error);
304
- }
305
- }
306
-
307
- export const slideVerticalTransition: Transition = async (direction: NavigationDirection, next) => {
308
- try {
309
- const transition = document.startViewTransition(() => {
310
- next();
311
- });
312
-
313
- await transition.ready;
314
-
315
- document.documentElement.animate(
316
- [
317
- { transform: 'translateY(0)' },
318
- { transform: direction === "forward" ? 'translateY(-100%)' : 'translateY(100%)' }
319
- ],
320
- {
321
- duration: 200,
322
- easing: "ease-in-out",
323
- fill: "both",
324
- pseudoElement: `::view-transition-old(root)`,
325
- }
326
- );
327
-
328
- document.documentElement.animate(
329
- [
330
- { transform: direction === "forward" ? 'translateY(100%)' : 'translateY(-100%)' },
331
- { transform: 'translateY(0)' }
332
- ],
333
- {
334
- duration: 200,
335
- easing: "ease-in-out",
336
- fill: "both",
337
- pseudoElement: `::view-transition-new(root)`,
338
- }
339
- );
340
- } catch (error) {
341
- console.error(error);
342
- }
343
- }
344
-
345
- export function createRouter<Locale extends string = never, Path extends string = never>({ prefix: expectedPrefix, locales, pages, fallback: Fallback, issue: Issue, transition }: Router<Path, Locale>) {
346
- const RouterContext = createContext<RouterContextInterface<Locale>>({
347
- locale: null,
348
- prefix: null,
349
- path: "/",
350
- setLocale: () => { },
351
- });
352
-
353
-
354
- function useIsActivePage<P extends Path>(page: Page<P>): boolean {
355
- const { path } = usePath();
356
- return matchPath(page.path, path);
357
- }
358
-
359
- function useLocale() {
360
- const context = useContext(RouterContext);
361
-
362
- if (!context) {
363
- throw new Error("component using the useLocale hook has not been wrapped inside RouterProvider.");
364
- }
365
-
366
- return {
367
- locale: context.locale,
368
- setLocale: (locale: Locale) => {
369
- if (!locales?.includes(locale)) {
370
- return;
371
- }
372
-
373
- window.history.pushState(null, "", `/${normalize(`${context.prefix ?? ""}/${locale}/${context.path}`)}`);
374
- window.dispatchEvent(new Event("pushstate"));
375
- }
376
- }
377
- }
378
-
379
- function usePrefix() {
380
- const context = useContext(RouterContext);
381
-
382
- if (!context) {
383
- throw new Error("Component using the usePrefix hook has not been wrapped inside RouterProvider");
384
- }
385
-
386
- return {
387
- prefix: context.prefix
388
- };
389
- }
390
-
391
- function usePath() {
392
- const context = useContext(RouterContext);
393
-
394
- if (!context) {
395
- throw new Error("Component using the usePath hook has not been wrapped inside RouterProvider");
396
- }
397
-
398
- return {
399
- path: context.path,
400
- };
401
- }
402
-
403
- function useNavigateToPage<P extends Path>(page: Page<P>) {
404
- const { locale } = useLocale();
405
- const { prefix } = usePrefix();
406
-
407
- return useCallback((...[params]: ExtractParams<P> extends never ? [] : [Params<P>]) => {
408
- const path = Object.entries(params ?? {}).reduce<string>((oldParams, [name, value]) => {
409
- return oldParams.replace(`:${name}`, String(value));
410
- }, page.path);
411
-
412
- const pathname = `/${normalize(`${prefix ?? ""}/${locale ?? ""}/${path}`)}`
413
-
414
- console.log({ pathname });
415
-
416
- window.history.pushState(null, "", pathname);
417
- window.dispatchEvent(new Event("pushstate"));
418
- }, [page, locale, prefix]);
419
- }
420
-
421
- function RouterProvider({ children }: RouterProviderProps) {
422
- const uri = useMemo(() => Uri.from(window.location.pathname, expectedPrefix, locales), [expectedPrefix, locales]);
423
- const [locale, setLocale] = useState(uri.locale);
424
- const [path, setPath] = useState(uri.path);
425
- const [prefix, setPrefix] = useState(uri.prefix);
426
-
427
- function setHash(newHash: string) {
428
- window.location.hash = newHash;
429
- }
430
-
431
- const value = useMemo(() => {
432
- return {
433
- locale,
434
- path,
435
- prefix: prefix ?? null,
436
- setLocale,
437
- setHash,
438
- };
439
- }, [locale, prefix, path]);
440
-
441
- const onNavigation = useEffectEvent((direction: NavigationDirection) => {
442
- const pathname = normalize(window.location.pathname);
443
- const uri = Uri.from(pathname, expectedPrefix, locales);
444
-
445
- const defaultTransition: Transition = (_, next) => {
446
- next();
447
- }
448
-
449
- const currentTransition: Transition = transition ?? defaultTransition;
450
-
451
- currentTransition(direction, () => {
452
- setLocale(uri.locale);
453
- setPath(uri.path);
454
- setPrefix(uri.prefix);
455
- });
456
- });
457
-
458
- const onMount = useEffectEvent(() => {
459
- const pathname = `/${normalize(window.location.pathname)}`;
460
- const uri = Uri.from(pathname, expectedPrefix, [locale, ...locales ?? []]);
461
- const newPathname = `/${normalize(`${uri.prefix ?? expectedPrefix ?? ""}/${uri.locale ?? locales?.at(0) ?? ""}/${uri.path}`)}`;
462
-
463
- if (newPathname !== pathname) {
464
- window.history.pushState(null, "", newPathname);
465
- window.dispatchEvent(new Event("pushstate"));
466
- }
467
- });
468
-
469
- useEffect(() => {
470
- const abortController = new AbortController();
471
-
472
- window.addEventListener("pushstate", () => {
473
- onNavigation("forward");
474
- }, abortController);
475
-
476
- window.addEventListener("popstate", () => {
477
- onNavigation("backward");
478
- }, abortController);
479
-
480
- onMount();
481
-
482
- return () => {
483
- abortController.abort();
484
- };
485
- }, []);
486
-
487
- return (
488
- <RouterContext.Provider value={value}>
489
- {children}
490
- </RouterContext.Provider>
491
- );
492
- }
493
-
494
- function RouterView() {
495
- const { path } = usePath();
496
-
497
- const foundPage = useMemo(() => {
498
- return pages.find(page => {
499
- return matchPath(page.path, path);
500
- });
501
- }, [path, pages]);
502
-
503
- if (foundPage) {
504
- return (
505
- <ErrorBoundary issue={Issue}>
506
- <foundPage.element parameters={matchParameters(foundPage.path, path) as Params<Path>} />
507
- </ErrorBoundary>
508
- );
509
- }
510
-
511
- return (
512
- <ErrorBoundary issue={Issue}>
513
- <Fallback />
514
- </ErrorBoundary>
515
- );
516
- }
517
-
518
- return {
519
- RouterProvider,
520
- RouterView,
521
- useLocale,
522
- usePrefix,
523
- usePath,
524
- useNavigateToPage,
525
- useIsActivePage,
526
- };
527
- }