@alepha/react 0.6.3 → 0.6.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.
Files changed (37) hide show
  1. package/dist/index.browser.cjs +2 -1
  2. package/dist/index.browser.cjs.map +1 -0
  3. package/dist/index.browser.js +3 -2
  4. package/dist/index.browser.js.map +1 -0
  5. package/dist/index.cjs +4 -3
  6. package/dist/index.cjs.map +1 -0
  7. package/dist/index.d.ts +34 -24
  8. package/dist/index.js +6 -5
  9. package/dist/index.js.map +1 -0
  10. package/dist/{useActive-dAmCT31a.js → useActive-BzjLwZjs.js} +103 -78
  11. package/dist/useActive-BzjLwZjs.js.map +1 -0
  12. package/dist/{useActive-BVqdq757.cjs → useActive-Ce3Xvs5V.cjs} +102 -77
  13. package/dist/useActive-Ce3Xvs5V.cjs.map +1 -0
  14. package/package.json +46 -38
  15. package/src/components/Link.tsx +37 -0
  16. package/src/components/NestedView.tsx +38 -0
  17. package/src/contexts/RouterContext.ts +16 -0
  18. package/src/contexts/RouterLayerContext.ts +10 -0
  19. package/src/descriptors/$page.ts +142 -0
  20. package/src/errors/RedirectionError.ts +7 -0
  21. package/src/hooks/RouterHookApi.ts +156 -0
  22. package/src/hooks/useActive.ts +57 -0
  23. package/src/hooks/useClient.ts +6 -0
  24. package/src/hooks/useInject.ts +14 -0
  25. package/src/hooks/useQueryParams.ts +59 -0
  26. package/src/hooks/useRouter.ts +25 -0
  27. package/src/hooks/useRouterEvents.ts +58 -0
  28. package/src/hooks/useRouterState.ts +21 -0
  29. package/src/index.browser.ts +21 -0
  30. package/src/index.shared.ts +15 -0
  31. package/src/index.ts +63 -0
  32. package/src/providers/BrowserHeadProvider.ts +43 -0
  33. package/src/providers/BrowserRouterProvider.ts +152 -0
  34. package/src/providers/PageDescriptorProvider.ts +522 -0
  35. package/src/providers/ReactBrowserProvider.ts +232 -0
  36. package/src/providers/ReactServerProvider.ts +286 -0
  37. package/src/providers/ServerHeadProvider.ts +91 -0
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+ import { useContext, useState } from "react";
3
+ import { RouterContext } from "../contexts/RouterContext.ts";
4
+ import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
5
+ import { useRouterEvents } from "../hooks/useRouterEvents.ts";
6
+
7
+ export interface NestedViewProps {
8
+ children?: ReactNode;
9
+ }
10
+
11
+ /**
12
+ * Nested view component
13
+ *
14
+ * @param props
15
+ * @constructor
16
+ */
17
+ const NestedView = (props: NestedViewProps) => {
18
+ const app = useContext(RouterContext);
19
+ const layer = useContext(RouterLayerContext);
20
+ const index = layer?.index ?? 0;
21
+
22
+ const [view, setView] = useState<ReactNode | undefined>(
23
+ app?.state.layers[index]?.element,
24
+ );
25
+
26
+ useRouterEvents(
27
+ {
28
+ onEnd: ({ state }) => {
29
+ setView(state.layers[index]?.element);
30
+ },
31
+ },
32
+ [app],
33
+ );
34
+
35
+ return view ?? props.children ?? null;
36
+ };
37
+
38
+ export default NestedView;
@@ -0,0 +1,16 @@
1
+ import type { Alepha } from "@alepha/core";
2
+ import { createContext } from "react";
3
+ import type {
4
+ PageReactContext,
5
+ RouterState,
6
+ } from "../providers/PageDescriptorProvider.ts";
7
+
8
+ export interface RouterContextValue {
9
+ alepha: Alepha;
10
+ state: RouterState;
11
+ context: PageReactContext;
12
+ }
13
+
14
+ export const RouterContext = createContext<RouterContextValue | undefined>(
15
+ undefined,
16
+ );
@@ -0,0 +1,10 @@
1
+ import { createContext } from "react";
2
+
3
+ export interface RouterLayerContextValue {
4
+ index: number;
5
+ path: string;
6
+ }
7
+
8
+ export const RouterLayerContext = createContext<
9
+ RouterLayerContextValue | undefined
10
+ >(undefined);
@@ -0,0 +1,142 @@
1
+ import { type Async, OPTIONS, type Static, type TSchema } from "@alepha/core";
2
+ import { KIND, NotImplementedError, __descriptor } from "@alepha/core";
3
+ import type { FC } from "react";
4
+ import type { RouterHookApi } from "../hooks/RouterHookApi.ts";
5
+
6
+ const KEY = "PAGE";
7
+
8
+ export interface PageConfigSchema {
9
+ query?: TSchema;
10
+ params?: TSchema;
11
+ }
12
+
13
+ export type TPropsDefault = any;
14
+
15
+ export type TPropsParentDefault = object;
16
+
17
+ export interface PageDescriptorOptions<
18
+ TConfig extends PageConfigSchema = PageConfigSchema,
19
+ TProps extends object = TPropsDefault,
20
+ TPropsParent extends object = TPropsParentDefault,
21
+ > {
22
+ name?: string;
23
+
24
+ path?: string;
25
+
26
+ schema?: TConfig;
27
+
28
+ resolve?: (config: PageResolve<TConfig, TPropsParent>) => Async<TProps>;
29
+
30
+ component?: FC<TProps & TPropsParent>;
31
+
32
+ lazy?: () => Promise<{ default: FC<TProps & TPropsParent> }>;
33
+
34
+ children?: Array<{ [OPTIONS]: PageDescriptorOptions }>;
35
+
36
+ parent?: { [OPTIONS]: PageDescriptorOptions<any, TPropsParent> };
37
+
38
+ can?: () => boolean;
39
+
40
+ head?: Head | ((props: TProps, previous?: Head) => Head);
41
+
42
+ errorHandler?: FC<{ error: Error; url: string }>;
43
+ }
44
+
45
+ export interface PageDescriptor<
46
+ TConfig extends PageConfigSchema = PageConfigSchema,
47
+ TProps extends object = TPropsDefault,
48
+ TPropsParent extends object = TPropsParentDefault,
49
+ > {
50
+ [KIND]: typeof KEY;
51
+ [OPTIONS]: PageDescriptorOptions<TConfig, TProps, TPropsParent>;
52
+
53
+ render: (options?: {
54
+ params?: Record<string, string>;
55
+ query?: Record<string, string>;
56
+ }) => Promise<string>;
57
+ go: () => void;
58
+ createAnchorProps: (routerHook: RouterHookApi) => {
59
+ href: string;
60
+ onClick: () => void;
61
+ };
62
+ can: () => boolean;
63
+ }
64
+
65
+ export const $page = <
66
+ TConfig extends PageConfigSchema = PageConfigSchema,
67
+ TProps extends object = TPropsDefault,
68
+ TPropsParent extends object = TPropsParentDefault,
69
+ >(
70
+ options: PageDescriptorOptions<TConfig, TProps, TPropsParent>,
71
+ ): PageDescriptor<TConfig, TProps, TPropsParent> => {
72
+ __descriptor(KEY);
73
+
74
+ if (options.children) {
75
+ for (const child of options.children) {
76
+ child[OPTIONS].parent = {
77
+ [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
78
+ };
79
+ }
80
+ }
81
+
82
+ if (options.parent) {
83
+ options.parent[OPTIONS].children ??= [];
84
+ options.parent[OPTIONS].children.push({
85
+ [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
86
+ });
87
+ }
88
+
89
+ return {
90
+ [KIND]: KEY,
91
+ [OPTIONS]: options,
92
+ render: () => {
93
+ throw new NotImplementedError(KEY);
94
+ },
95
+ go: () => {
96
+ throw new NotImplementedError(KEY);
97
+ },
98
+ createAnchorProps: () => {
99
+ throw new NotImplementedError(KEY);
100
+ },
101
+ can: () => {
102
+ if (options.can) {
103
+ return options.can();
104
+ }
105
+ return true;
106
+ },
107
+ };
108
+ };
109
+
110
+ $page[KIND] = KEY;
111
+
112
+ // ---------------------------------------------------------------------------------------------------------------------
113
+
114
+ export interface Head {
115
+ title?: string;
116
+ titleSeparator?: string;
117
+ htmlAttributes?: Record<string, string>;
118
+ bodyAttributes?: Record<string, string>;
119
+ meta?: Array<{ name: string; content: string }>;
120
+ }
121
+
122
+ export interface PageRequestConfig<
123
+ TConfig extends PageConfigSchema = PageConfigSchema,
124
+ > {
125
+ params: TConfig["params"] extends TSchema
126
+ ? Static<TConfig["params"]>
127
+ : Record<string, string>;
128
+
129
+ query: TConfig["query"] extends TSchema
130
+ ? Static<TConfig["query"]>
131
+ : Record<string, string>;
132
+ }
133
+
134
+ export type PageResolve<
135
+ TConfig extends PageConfigSchema = PageConfigSchema,
136
+ TPropsParent extends object = TPropsParentDefault,
137
+ > = PageRequestConfig<TConfig> & TPropsParent & PageResolveContext;
138
+
139
+ export interface PageResolveContext {
140
+ url: URL;
141
+ head: Head;
142
+ }
@@ -0,0 +1,7 @@
1
+ import type { HrefLike } from "../hooks/RouterHookApi.ts";
2
+
3
+ export class RedirectionError extends Error {
4
+ constructor(public readonly page: HrefLike) {
5
+ super("Redirection");
6
+ }
7
+ }
@@ -0,0 +1,156 @@
1
+ import type {
2
+ AnchorProps,
3
+ RouterState,
4
+ } from "../providers/PageDescriptorProvider.ts";
5
+ import type {
6
+ ReactBrowserProvider,
7
+ RouterGoOptions,
8
+ } from "../providers/ReactBrowserProvider.ts";
9
+
10
+ export class RouterHookApi {
11
+ constructor(
12
+ private readonly state: RouterState,
13
+ private readonly layer: {
14
+ path: string;
15
+ },
16
+ private readonly browser?: ReactBrowserProvider,
17
+ ) {}
18
+
19
+ /**
20
+ *
21
+ */
22
+ public get current(): RouterState {
23
+ return this.state;
24
+ }
25
+
26
+ /**
27
+ *
28
+ */
29
+ public get pathname(): string {
30
+ return this.state.pathname;
31
+ }
32
+
33
+ /**
34
+ *
35
+ */
36
+ public get query(): Record<string, string> {
37
+ const query: Record<string, string> = {};
38
+
39
+ for (const [key, value] of new URLSearchParams(
40
+ this.state.search,
41
+ ).entries()) {
42
+ query[key] = String(value);
43
+ }
44
+
45
+ return query;
46
+ }
47
+
48
+ /**
49
+ *
50
+ */
51
+ public async back() {
52
+ this.browser?.history.back();
53
+ }
54
+
55
+ /**
56
+ *
57
+ */
58
+ public async forward() {
59
+ this.browser?.history.forward();
60
+ }
61
+
62
+ /**
63
+ *
64
+ * @param props
65
+ */
66
+ public async invalidate(props?: Record<string, any>) {
67
+ await this.browser?.invalidate(props);
68
+ }
69
+
70
+ /**
71
+ * Create a valid href for the given pathname.
72
+ *
73
+ * @param pathname
74
+ * @param layer
75
+ */
76
+ public createHref(pathname: HrefLike, layer: { path: string } = this.layer) {
77
+ if (typeof pathname === "object") {
78
+ pathname = pathname.options.path ?? "";
79
+ }
80
+
81
+ return pathname.startsWith("/")
82
+ ? pathname
83
+ : `${layer.path}/${pathname}`.replace(/\/\/+/g, "/");
84
+ }
85
+
86
+ /**
87
+ *
88
+ * @param path
89
+ * @param options
90
+ */
91
+ public async go(
92
+ path: HrefLike,
93
+ options: RouterGoOptions = {},
94
+ ): Promise<void> {
95
+ return await this.browser?.go(this.createHref(path, this.layer), options);
96
+ }
97
+
98
+ /**
99
+ *
100
+ * @param path
101
+ */
102
+ public createAnchorProps(path: string): AnchorProps {
103
+ const href = this.createHref(path, this.layer);
104
+ return {
105
+ href,
106
+ onClick: (ev: any) => {
107
+ ev.stopPropagation();
108
+ ev.preventDefault();
109
+
110
+ this.go(path).catch(console.error);
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Set query params.
117
+ *
118
+ * @param record
119
+ * @param options
120
+ */
121
+ public setQueryParams(
122
+ record: Record<string, any>,
123
+ options: {
124
+ /**
125
+ * If true, this will merge current query params with the new ones.
126
+ */
127
+ merge?: boolean;
128
+
129
+ /**
130
+ * If true, this will add a new entry to the history stack.
131
+ */
132
+ push?: boolean;
133
+ } = {},
134
+ ) {
135
+ const search = new URLSearchParams(
136
+ options.merge
137
+ ? {
138
+ ...this.query,
139
+ ...record,
140
+ }
141
+ : {
142
+ ...record,
143
+ },
144
+ ).toString();
145
+
146
+ const state = search ? `${this.pathname}?${search}` : this.pathname;
147
+
148
+ if (options.push) {
149
+ window.history.pushState({}, "", state);
150
+ } else {
151
+ window.history.replaceState({}, "", state);
152
+ }
153
+ }
154
+ }
155
+
156
+ export type HrefLike = string | { options: { path?: string; name?: string } };
@@ -0,0 +1,57 @@
1
+ import { useContext, useMemo, useState } from "react";
2
+ import { RouterContext } from "../contexts/RouterContext.ts";
3
+ import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
4
+ import type { AnchorProps } from "../providers/PageDescriptorProvider.ts";
5
+ import type { HrefLike } from "./RouterHookApi.ts";
6
+ import { useRouter } from "./useRouter.ts";
7
+ import { useRouterEvents } from "./useRouterEvents.ts";
8
+
9
+ export const useActive = (path: HrefLike): UseActiveHook => {
10
+ const router = useRouter();
11
+ const ctx = useContext(RouterContext);
12
+ const layer = useContext(RouterLayerContext);
13
+ if (!ctx || !layer) {
14
+ throw new Error("useRouter must be used within a RouterProvider");
15
+ }
16
+
17
+ let name: string | undefined;
18
+ if (typeof path === "object" && path.options.name) {
19
+ name = path.options.name;
20
+ }
21
+
22
+ const [current, setCurrent] = useState(ctx.state.pathname);
23
+ const href = useMemo(() => router.createHref(path, layer), [path, layer]);
24
+ const [isPending, setPending] = useState(false);
25
+ const isActive = current === href;
26
+
27
+ useRouterEvents({
28
+ onEnd: ({ state }) => setCurrent(state.pathname),
29
+ });
30
+
31
+ return {
32
+ name,
33
+ isPending,
34
+ isActive,
35
+ anchorProps: {
36
+ href,
37
+ onClick: (ev: any) => {
38
+ ev.stopPropagation();
39
+ ev.preventDefault();
40
+ if (isActive) return;
41
+ if (isPending) return;
42
+
43
+ setPending(true);
44
+ router.go(href).then(() => {
45
+ setPending(false);
46
+ });
47
+ },
48
+ },
49
+ };
50
+ };
51
+
52
+ export interface UseActiveHook {
53
+ isActive: boolean;
54
+ anchorProps: AnchorProps;
55
+ isPending: boolean;
56
+ name?: string;
57
+ }
@@ -0,0 +1,6 @@
1
+ import { HttpClient } from "@alepha/server";
2
+ import { useInject } from "./useInject.ts";
3
+
4
+ export const useClient = (): HttpClient => {
5
+ return useInject(HttpClient);
6
+ };
@@ -0,0 +1,14 @@
1
+ import type { Class } from "@alepha/core";
2
+ import { useContext } from "react";
3
+ import { RouterContext } from "../contexts/RouterContext.ts";
4
+
5
+ export const useInject = <T extends object>(clazz: Class<T>): T => {
6
+ const ctx = useContext(RouterContext);
7
+ if (!ctx) {
8
+ throw new Error("useRouter must be used within a <RouterProvider>");
9
+ }
10
+
11
+ return ctx.alepha.get(clazz, {
12
+ skipRegistration: true,
13
+ });
14
+ };
@@ -0,0 +1,59 @@
1
+ import type { Alepha, Static, TObject } from "@alepha/core";
2
+ import { useContext, useEffect, useState } from "react";
3
+ import { RouterContext } from "../contexts/RouterContext.ts";
4
+ import { useRouter } from "./useRouter.ts";
5
+
6
+ export interface UseQueryParamsHookOptions {
7
+ format?: "base64" | "querystring";
8
+ key?: string;
9
+ push?: boolean;
10
+ }
11
+
12
+ export const useQueryParams = <T extends TObject>(
13
+ schema: T,
14
+ options: UseQueryParamsHookOptions = {},
15
+ ): [Static<T>, (data: Static<T>) => void] => {
16
+ const ctx = useContext(RouterContext);
17
+ if (!ctx) {
18
+ throw new Error("useQueryParams must be used within a RouterProvider");
19
+ }
20
+
21
+ const key = options.key ?? "q";
22
+ const router = useRouter();
23
+ const querystring = router.query[key];
24
+
25
+ const [queryParams, setQueryParams] = useState(
26
+ decode(ctx.alepha, schema, router.query[key]),
27
+ );
28
+
29
+ useEffect(() => {
30
+ setQueryParams(decode(ctx.alepha, schema, querystring));
31
+ }, [querystring]);
32
+
33
+ return [
34
+ queryParams,
35
+ (queryParams: Static<T>) => {
36
+ setQueryParams(queryParams);
37
+ router.setQueryParams(
38
+ { [key]: encode(ctx.alepha, schema, queryParams) },
39
+ {
40
+ merge: true,
41
+ },
42
+ );
43
+ },
44
+ ];
45
+ };
46
+
47
+ // ---------------------------------------------------------------------------------------------------------------------
48
+
49
+ const encode = (alepha: Alepha, schema: TObject, data: any) => {
50
+ return btoa(JSON.stringify(alepha.parse(schema, data)));
51
+ };
52
+
53
+ const decode = (alepha: Alepha, schema: TObject, data: any) => {
54
+ try {
55
+ return alepha.parse(schema, JSON.parse(atob(decodeURIComponent(data))));
56
+ } catch (error) {
57
+ return {};
58
+ }
59
+ };
@@ -0,0 +1,25 @@
1
+ import { useContext, useMemo } from "react";
2
+ import { RouterContext } from "../contexts/RouterContext.ts";
3
+ import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
4
+ import { ReactBrowserProvider } from "../providers/ReactBrowserProvider.ts";
5
+ import { RouterHookApi } from "./RouterHookApi.ts";
6
+
7
+ export const useRouter = (): RouterHookApi => {
8
+ const ctx = useContext(RouterContext);
9
+ const layer = useContext(RouterLayerContext);
10
+ if (!ctx || !layer) {
11
+ throw new Error("useRouter must be used within a RouterProvider");
12
+ }
13
+
14
+ return useMemo(
15
+ () =>
16
+ new RouterHookApi(
17
+ ctx.state,
18
+ layer,
19
+ ctx.alepha.isBrowser()
20
+ ? ctx.alepha.get(ReactBrowserProvider)
21
+ : undefined,
22
+ ),
23
+ [layer],
24
+ );
25
+ };
@@ -0,0 +1,58 @@
1
+ import { useContext, useEffect } from "react";
2
+ import { RouterContext } from "../contexts/RouterContext.ts";
3
+ import type { RouterState } from "../providers/PageDescriptorProvider.ts";
4
+
5
+ export const useRouterEvents = (
6
+ opts: {
7
+ onBegin?: (ev: { state: RouterState }) => void;
8
+ onEnd?: (ev: { state: RouterState }) => void;
9
+ onError?: (ev: { state: RouterState; error: Error }) => void;
10
+ } = {},
11
+ deps: any[] = [],
12
+ ) => {
13
+ const ctx = useContext(RouterContext);
14
+ if (!ctx) {
15
+ throw new Error("useRouter must be used within a RouterProvider");
16
+ }
17
+
18
+ useEffect(() => {
19
+ if (!ctx.alepha.isBrowser()) {
20
+ return;
21
+ }
22
+
23
+ const subs: Function[] = [];
24
+ const onBegin = opts.onBegin;
25
+ const onEnd = opts.onEnd;
26
+ const onError = opts.onError;
27
+
28
+ if (onBegin) {
29
+ subs.push(
30
+ ctx.alepha.on("react:transition:begin", {
31
+ callback: onBegin,
32
+ }),
33
+ );
34
+ }
35
+
36
+ if (onEnd) {
37
+ subs.push(
38
+ ctx.alepha.on("react:transition:end", {
39
+ callback: onEnd,
40
+ }),
41
+ );
42
+ }
43
+
44
+ if (onError) {
45
+ subs.push(
46
+ ctx.alepha.on("react:transition:error", {
47
+ callback: onError,
48
+ }),
49
+ );
50
+ }
51
+
52
+ return () => {
53
+ for (const sub of subs) {
54
+ sub();
55
+ }
56
+ };
57
+ }, deps);
58
+ };
@@ -0,0 +1,21 @@
1
+ import { useContext, useState } from "react";
2
+ import { RouterContext } from "../contexts/RouterContext.ts";
3
+ import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
4
+ import type { RouterState } from "../providers/PageDescriptorProvider.ts";
5
+ import { useRouterEvents } from "./useRouterEvents.ts";
6
+
7
+ export const useRouterState = (): RouterState => {
8
+ const ctx = useContext(RouterContext);
9
+ const layer = useContext(RouterLayerContext);
10
+ if (!ctx || !layer) {
11
+ throw new Error("useRouter must be used within a RouterProvider");
12
+ }
13
+
14
+ const [state, setState] = useState(ctx.state);
15
+
16
+ useRouterEvents({
17
+ onEnd: ({ state }) => setState({ ...state }),
18
+ });
19
+
20
+ return state;
21
+ };
@@ -0,0 +1,21 @@
1
+ import { $inject, Alepha, __bind } from "@alepha/core";
2
+ import { $page } from "./descriptors/$page.ts";
3
+ import { BrowserRouterProvider } from "./providers/BrowserRouterProvider.ts";
4
+ import { PageDescriptorProvider } from "./providers/PageDescriptorProvider.ts";
5
+ import { ReactBrowserProvider } from "./providers/ReactBrowserProvider.ts";
6
+
7
+ export * from "./index.shared";
8
+ export * from "./providers/ReactBrowserProvider.ts";
9
+
10
+ export class ReactModule {
11
+ protected readonly alepha = $inject(Alepha);
12
+
13
+ constructor() {
14
+ this.alepha //
15
+ .with(PageDescriptorProvider)
16
+ .with(ReactBrowserProvider)
17
+ .with(BrowserRouterProvider);
18
+ }
19
+ }
20
+
21
+ __bind($page, ReactModule);
@@ -0,0 +1,15 @@
1
+ export { default as NestedView } from "./components/NestedView.tsx";
2
+ export { default as Link } from "./components/Link.tsx";
3
+
4
+ export * from "./contexts/RouterContext.ts";
5
+ export * from "./contexts/RouterLayerContext.ts";
6
+ export * from "./descriptors/$page.ts";
7
+ export * from "./hooks/RouterHookApi.ts";
8
+ export * from "./hooks/useInject.ts";
9
+ export * from "./hooks/useClient.ts";
10
+ export * from "./hooks/useQueryParams.ts";
11
+ export * from "./hooks/useRouter.ts";
12
+ export * from "./hooks/useRouterEvents.ts";
13
+ export * from "./hooks/useRouterState.ts";
14
+ export * from "./hooks/useActive.ts";
15
+ export * from "./errors/RedirectionError.ts";