@kasperrt/smolter 0.0.0-stage → 0.0.1-rc.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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +470 -2
  3. package/dist/client.d.ts +2 -0
  4. package/dist/client.js +2 -0
  5. package/dist/core.d.ts +2 -0
  6. package/dist/core.js +2 -0
  7. package/dist/files.d.ts +2 -0
  8. package/dist/files.js +2 -0
  9. package/dist/internal/client/deserialize.d.ts +3 -0
  10. package/dist/internal/client/deserialize.js +11 -0
  11. package/dist/internal/client/index.d.ts +23 -0
  12. package/dist/internal/client/index.js +44 -0
  13. package/dist/internal/core/errors.d.ts +3 -0
  14. package/dist/internal/core/errors.js +6 -0
  15. package/dist/internal/core/index.d.ts +6 -0
  16. package/dist/internal/core/index.js +6 -0
  17. package/dist/internal/core/match.d.ts +5 -0
  18. package/dist/internal/core/match.js +27 -0
  19. package/dist/internal/core/router.d.ts +3 -0
  20. package/dist/internal/core/router.js +189 -0
  21. package/dist/internal/core/types.d.ts +119 -0
  22. package/dist/internal/core/types.js +5 -0
  23. package/dist/internal/core/url.d.ts +2 -0
  24. package/dist/internal/core/url.js +6 -0
  25. package/dist/internal/files/index.d.ts +11 -0
  26. package/dist/internal/files/index.js +77 -0
  27. package/dist/internal/preact/context.d.ts +18 -0
  28. package/dist/internal/preact/context.js +20 -0
  29. package/dist/internal/preact/fetcher.d.ts +22 -0
  30. package/dist/internal/preact/fetcher.js +28 -0
  31. package/dist/internal/preact/form.d.ts +17 -0
  32. package/dist/internal/preact/form.js +41 -0
  33. package/dist/internal/preact/hooks.d.ts +21 -0
  34. package/dist/internal/preact/hooks.js +35 -0
  35. package/dist/internal/preact/index.d.ts +9 -0
  36. package/dist/internal/preact/index.js +7 -0
  37. package/dist/internal/preact/link.d.ts +12 -0
  38. package/dist/internal/preact/link.js +28 -0
  39. package/dist/internal/preact/routes.d.ts +22 -0
  40. package/dist/internal/preact/routes.js +22 -0
  41. package/dist/internal/preact/runtime.d.ts +2 -0
  42. package/dist/internal/preact/runtime.js +1 -0
  43. package/dist/internal/react/context.d.ts +18 -0
  44. package/dist/internal/react/context.js +20 -0
  45. package/dist/internal/react/fetcher.d.ts +22 -0
  46. package/dist/internal/react/fetcher.js +28 -0
  47. package/dist/internal/react/form.d.ts +17 -0
  48. package/dist/internal/react/form.js +41 -0
  49. package/dist/internal/react/hooks.d.ts +21 -0
  50. package/dist/internal/react/hooks.js +35 -0
  51. package/dist/internal/react/index.d.ts +9 -0
  52. package/dist/internal/react/index.js +7 -0
  53. package/dist/internal/react/link.d.ts +12 -0
  54. package/dist/internal/react/link.js +28 -0
  55. package/dist/internal/react/routes.d.ts +22 -0
  56. package/dist/internal/react/routes.js +22 -0
  57. package/dist/internal/react/runtime.d.ts +2 -0
  58. package/dist/internal/react/runtime.js +2 -0
  59. package/dist/internal/server/index.d.ts +11 -0
  60. package/dist/internal/server/index.js +81 -0
  61. package/dist/internal/vite/index.d.ts +10 -0
  62. package/dist/internal/vite/index.js +132 -0
  63. package/dist/internal/vite/transport.d.ts +2 -0
  64. package/dist/internal/vite/transport.js +42 -0
  65. package/dist/preact.d.ts +2 -0
  66. package/dist/preact.js +2 -0
  67. package/dist/react.d.ts +2 -0
  68. package/dist/react.js +2 -0
  69. package/dist/server.d.ts +2 -0
  70. package/dist/server.js +2 -0
  71. package/dist/vite.d.ts +2 -0
  72. package/dist/vite.js +2 -0
  73. package/jsr.json +26 -0
  74. package/package.json +94 -4
  75. package/src/client.ts +2 -0
  76. package/src/core.ts +2 -0
  77. package/src/files.ts +2 -0
  78. package/src/internal/client/deserialize.ts +15 -0
  79. package/src/internal/client/index.ts +84 -0
  80. package/src/internal/core/errors.ts +13 -0
  81. package/src/internal/core/index.ts +7 -0
  82. package/src/internal/core/match.ts +45 -0
  83. package/src/internal/core/router.ts +251 -0
  84. package/src/internal/core/types.ts +151 -0
  85. package/src/internal/core/url.ts +6 -0
  86. package/src/internal/files/index.ts +112 -0
  87. package/src/internal/preact/context.ts +46 -0
  88. package/src/internal/preact/fetcher.ts +83 -0
  89. package/src/internal/preact/form.ts +79 -0
  90. package/src/internal/preact/hooks.ts +72 -0
  91. package/src/internal/preact/index.ts +13 -0
  92. package/src/internal/preact/link.ts +46 -0
  93. package/src/internal/preact/routes.ts +50 -0
  94. package/src/internal/preact/runtime.ts +15 -0
  95. package/src/internal/react/context.ts +46 -0
  96. package/src/internal/react/fetcher.ts +83 -0
  97. package/src/internal/react/form.ts +79 -0
  98. package/src/internal/react/hooks.ts +72 -0
  99. package/src/internal/react/index.ts +13 -0
  100. package/src/internal/react/link.ts +46 -0
  101. package/src/internal/react/routes.ts +50 -0
  102. package/src/internal/react/runtime.ts +18 -0
  103. package/src/internal/server/index.ts +120 -0
  104. package/src/internal/vite/index.ts +210 -0
  105. package/src/internal/vite/transport.ts +60 -0
  106. package/src/preact.ts +2 -0
  107. package/src/react.ts +2 -0
  108. package/src/server.ts +2 -0
  109. package/src/vite.ts +2 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Framework-independent route matching, data requests, and state.
3
+ * @module
4
+ */
5
+
6
+ /** Framework-independent data routing. No global request state. */
7
+ export type Params = Record<string, string>;
8
+
9
+ /** Request, path parameters, and cancellation signal passed to a data handler. */
10
+ export interface DataArgs {
11
+ request: Request;
12
+ params: Params;
13
+ signal: AbortSignal;
14
+ }
15
+
16
+ /** A loader or action that returns data, a Response, or an Error value. */
17
+ export type DataFunction = (args: DataArgs) => unknown;
18
+
19
+ /** A handler signature used to infer data from specialized loader and action arguments. */
20
+ export type AnyDataFunction = (...args: never[]) => unknown;
21
+
22
+ /** Client loader arguments, including a typed call to the server loader. */
23
+ export interface ClientLoaderArgs<L extends AnyDataFunction = DataFunction>
24
+ extends DataArgs {
25
+ serverLoader: () => Promise<DataOf<L>>;
26
+ }
27
+
28
+ /** Client action arguments, including a typed call to the server action. */
29
+ export interface ClientActionArgs<A extends AnyDataFunction = DataFunction>
30
+ extends DataArgs {
31
+ serverAction: () => Promise<DataOf<A>>;
32
+ }
33
+
34
+ /** The resolved data returned by a handler, excluding Error values. */
35
+ export type DataOf<F> = F extends AnyDataFunction
36
+ ? Exclude<Awaited<ReturnType<F>>, Error>
37
+ : undefined;
38
+
39
+ /** Mutation details supplied when deciding whether to reload route data. */
40
+ export interface RevalidationContext {
41
+ formMethod?: string;
42
+ formAction?: string;
43
+ formData?: FormData;
44
+ actionResult?: unknown;
45
+ }
46
+
47
+ /** Current URL, mutation result, and reason passed to shouldRevalidate. */
48
+ export interface RevalidationArgs extends RevalidationContext {
49
+ currentUrl: URL;
50
+ nextUrl: URL;
51
+ defaultShouldRevalidate: boolean;
52
+ url: URL;
53
+ reason: "manual" | "action" | "navigation";
54
+ }
55
+
56
+ /** A route component and its optional server and client data handlers. */
57
+ export interface RouteModule {
58
+ default?: unknown;
59
+ HydrateFallback?: unknown;
60
+ ErrorBoundary?: unknown;
61
+ loader?: DataFunction;
62
+ action?: DataFunction;
63
+ clientLoader?(args: ClientLoaderArgs): unknown;
64
+ clientAction?(args: ClientActionArgs): unknown;
65
+ shouldRevalidate?: (args: RevalidationArgs) => boolean;
66
+ }
67
+
68
+ /** A route module associated with an explicit URL pattern. */
69
+ export type Route<M extends RouteModule = RouteModule> = M & { path: string };
70
+
71
+ /** The first matching route and its decoded path parameters. */
72
+ export interface Match {
73
+ route: Route;
74
+ params: Params;
75
+ }
76
+ /** Immutable current route state observed by framework hooks. */
77
+ export interface Snapshot {
78
+ url: string;
79
+ path?: string;
80
+ params: Params;
81
+ data?: unknown;
82
+ /** Whether this route has completed a loader, including an undefined result. */
83
+ loaded?: boolean;
84
+ actionData?: unknown;
85
+ error?: Error;
86
+ state: "idle" | "loading" | "submitting";
87
+ }
88
+
89
+ /** Initial URL, browser history, and hydration configuration. */
90
+ export interface RouterOptions {
91
+ url?: string;
92
+ /** Explicitly opt into browser history; leave off for SSR and tests. */
93
+ browser?: boolean;
94
+ hydrationData?: Snapshot;
95
+ }
96
+
97
+ /** Options for replacing rather than appending a browser history entry. */
98
+ export interface NavigateOptions {
99
+ replace?: boolean;
100
+ }
101
+
102
+ /** HTTP method for a route action submission; defaults to POST. */
103
+ export interface SubmitOptions {
104
+ method?: "POST" | "PUT" | "PATCH" | "DELETE";
105
+ }
106
+
107
+ /** A resolved operation result, with either data or a normalized Error. */
108
+ export interface Result<T = unknown> {
109
+ data?: T;
110
+ error?: Error;
111
+ }
112
+
113
+ /** Independent fetcher data, error, and pending state. */
114
+ export interface FetcherSnapshot extends Result {
115
+ state: Snapshot["state"];
116
+ }
117
+
118
+ /** An independent loader/action channel that does not navigate the page. */
119
+ export interface Fetcher {
120
+ getSnapshot: () => FetcherSnapshot;
121
+ subscribe: (listener: () => void) => () => void;
122
+ load: (url: string) => Promise<Result>;
123
+ submit: (
124
+ url: string,
125
+ body: FormData,
126
+ options?: SubmitOptions,
127
+ ) => Promise<Result>;
128
+ /** Abort work and release subscriptions when this imperative fetcher is no longer used. */
129
+ dispose: () => void;
130
+ }
131
+
132
+ /** Route navigation, subscriptions, fetchers, and loader revalidation. */
133
+ export interface Router {
134
+ routes: readonly Route[];
135
+ getSnapshot: () => Snapshot;
136
+ subscribe: (listener: () => void) => () => void;
137
+ start: () => () => void;
138
+ navigate: (url: string, options?: NavigateOptions) => Promise<void>;
139
+ revalidate: (
140
+ reason?: RevalidationArgs["reason"],
141
+ context?: RevalidationContext,
142
+ ) => Promise<void>;
143
+ submit: (
144
+ url: string,
145
+ body: FormData,
146
+ options?: SubmitOptions,
147
+ ) => Promise<Result>;
148
+ /** Create an independent fetcher. Framework useFetcher hooks manage disposal automatically. */
149
+ createFetcher: () => Fetcher;
150
+ dispose: () => void;
151
+ }
@@ -0,0 +1,6 @@
1
+ /** Resolve an internal URL; external links remain the browser's responsibility. */
2
+ export function internalURL(base: string, href = base): URL | undefined {
3
+ const url = new URL(href, base);
4
+
5
+ if (url.origin === new URL(base).origin) return url;
6
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Build-time discovery of flat file routes.
3
+ * @module
4
+ */
5
+
6
+ import { readdir } from "node:fs/promises";
7
+ import { resolve } from "node:path";
8
+ import { safeWrapAsync } from "../core/index.ts";
9
+
10
+ /** A discovered flat route pattern and its module file path. */
11
+ export interface RouteFile {
12
+ path: string;
13
+ file: string;
14
+ }
15
+
16
+ const routeFile = /^route\.[jt]sx?$/;
17
+ const flatFile = /\.[jt]sx?$/;
18
+
19
+ /** Read only flat files and immediate route folders. Colocated helpers are ignored. */
20
+ export async function flatRoutes(
21
+ directory: string,
22
+ ): Promise<RouteFile[] | Error> {
23
+ const result = await safeWrapAsync(async () => {
24
+ const entries = await readdir(directory, { withFileTypes: true });
25
+ const routes: RouteFile[] = [];
26
+ const patterns = new Set<string>();
27
+
28
+ for (const entry of entries) {
29
+ let name = entry.name;
30
+ let file = resolve(directory, name);
31
+
32
+ if (entry.isDirectory()) {
33
+ const candidates = (await readdir(file)).filter((name) =>
34
+ routeFile.test(name),
35
+ );
36
+
37
+ if (candidates.length > 1)
38
+ return new Error(`Multiple route files in ${file}`);
39
+
40
+ if (!candidates.length) continue;
41
+
42
+ file = resolve(file, candidates[0]);
43
+ } else {
44
+ if (!entry.isFile() || !flatFile.test(name) || name.endsWith(".d.ts"))
45
+ continue;
46
+
47
+ name = name.replace(flatFile, "");
48
+ }
49
+
50
+ if (/[()[\]:*?]/.test(name))
51
+ return new Error(`Unsupported flat route name: ${name}`);
52
+
53
+ const segments = name.split(".");
54
+ const parts: string[] = [];
55
+
56
+ for (const [index, segment] of segments.entries()) {
57
+ if (!segment) return new Error(`Empty route segment: ${name}`);
58
+
59
+ if (segment === "_index" && index !== segments.length - 1)
60
+ return new Error(`_index must be last: ${name}`);
61
+
62
+ if (segment === "$" && index !== segments.length - 1)
63
+ return new Error(`Splat must be last: ${name}`);
64
+
65
+ if (segment.startsWith("_")) continue;
66
+
67
+ const part = segment.replace(/_$/, "");
68
+ parts.push(part === "$" ? "*" : part.replace(/^\$/, ":"));
69
+ }
70
+
71
+ // Pathless grouping modules are not nested layouts in a flat router.
72
+ if (segments.at(-1)?.startsWith("_") && segments.at(-1) !== "_index")
73
+ continue;
74
+
75
+ const path = `/${parts.join("/")}`;
76
+ const pattern = path.replace(/:[^/]+/g, ":");
77
+
78
+ if (patterns.has(pattern))
79
+ return new Error(`Conflicting flat route: ${path}`);
80
+
81
+ patterns.add(pattern);
82
+ routes.push({ path, file });
83
+ }
84
+
85
+ return routes.sort((a, b) => {
86
+ const left = a.path.split("/");
87
+ const right = b.path.split("/");
88
+ const rank = (part: string | undefined) =>
89
+ part === "*"
90
+ ? 0
91
+ : part?.startsWith(":")
92
+ ? 1
93
+ : part === undefined
94
+ ? 2
95
+ : 3;
96
+
97
+ for (
98
+ let index = 0;
99
+ index < Math.max(left.length, right.length);
100
+ index++
101
+ ) {
102
+ const difference = rank(right[index]) - rank(left[index]);
103
+
104
+ if (difference) return difference;
105
+ }
106
+
107
+ return a.path.localeCompare(b.path);
108
+ });
109
+ });
110
+
111
+ return result.error ?? result.data!;
112
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Preact components and typed data hooks. Import from @kasperrt/smolter/preact.
3
+ * @module
4
+ */
5
+
6
+ import type { Router, Snapshot } from "../core/index.ts";
7
+ import type { ReactNode } from "./runtime.ts";
8
+ import {
9
+ createContext,
10
+ createElement,
11
+ useContext,
12
+ useEffect,
13
+ useSyncExternalStore,
14
+ } from "./runtime.ts";
15
+
16
+ const RouterContext = createContext<Router | undefined>(undefined);
17
+
18
+ /** Router instance and children rendered within its context. */
19
+ export interface RouterProviderProps {
20
+ router: Router;
21
+ children?: ReactNode;
22
+ }
23
+
24
+ /** Provide a router to descendant hooks and connect its browser lifecycle. */
25
+ export function RouterProvider({
26
+ router,
27
+ children,
28
+ }: RouterProviderProps): ReactNode {
29
+ useEffect(router.start, [router]);
30
+
31
+ return createElement(RouterContext.Provider, { value: router }, children);
32
+ }
33
+ /** Hooks require a RouterProvider. */
34
+ export function useRouter(): Router {
35
+ return useContext(RouterContext) as Router;
36
+ }
37
+
38
+ type SnapshotSelector<T> = (snapshot: Snapshot) => T;
39
+
40
+ export function useSnapshot<T>(select: SnapshotSelector<T>): T {
41
+ const router = useRouter();
42
+
43
+ const getSnapshot = () => select(router.getSnapshot());
44
+
45
+ return useSyncExternalStore(router.subscribe, getSnapshot, getSnapshot);
46
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Preact components and typed data hooks. Import from @kasperrt/smolter/preact.
3
+ * @module
4
+ */
5
+
6
+ import type {
7
+ AnyDataFunction,
8
+ DataFunction,
9
+ DataOf,
10
+ FetcherSnapshot,
11
+ Result,
12
+ Route as RouteDefinition,
13
+ RouteModule,
14
+ SubmitOptions,
15
+ } from "../core/index.ts";
16
+ import { useRouter } from "./context.ts";
17
+ import { Form, type FormProps } from "./form.ts";
18
+ import type { ComponentType } from "./runtime.ts";
19
+ import {
20
+ createElement,
21
+ useEffect,
22
+ useMemo,
23
+ useSyncExternalStore,
24
+ } from "./runtime.ts";
25
+
26
+ /** Action method and optional explicit route URL for a fetcher submission. */
27
+ export interface FetcherSubmitOptions extends SubmitOptions {
28
+ action?: string;
29
+ }
30
+
31
+ /** Typed independent request state, methods, and a bound Form component. */
32
+ export interface TypedFetcher<T> extends Omit<FetcherSnapshot, "data"> {
33
+ data?: T;
34
+ Form: ComponentType<FormProps>;
35
+ load: (url?: string) => Promise<Result>;
36
+ submit: (body: FormData, options?: FetcherSubmitOptions) => Promise<Result>;
37
+ }
38
+
39
+ /** Create a fetcher for independent route reads and mutations; its data stays local. */
40
+ export function useFetcher<T extends RouteModule>(
41
+ route: RouteDefinition<T>,
42
+ ): TypedFetcher<
43
+ | DataOf<Extract<T["action"] | T["clientAction"], AnyDataFunction>>
44
+ | DataOf<Extract<T["loader"] | T["clientLoader"], AnyDataFunction>>
45
+ >;
46
+
47
+ /** Create a fetcher for independent route reads and mutations; its data stays local. */
48
+ export function useFetcher<
49
+ T extends AnyDataFunction = DataFunction,
50
+ >(): TypedFetcher<DataOf<T>>;
51
+
52
+ /** Create a fetcher for independent route reads and mutations; its data stays local. */
53
+ export function useFetcher(route?: RouteDefinition): TypedFetcher<unknown> {
54
+ const router = useRouter();
55
+ const path = route?.path;
56
+ const fetcher = useMemo(() => {
57
+ const store = router.createFetcher();
58
+ const target = () => {
59
+ const snapshot = router.getSnapshot();
60
+
61
+ return path === undefined || path === snapshot.path ? snapshot.url : path;
62
+ };
63
+
64
+ return {
65
+ ...store,
66
+ Form: (props: FormProps) =>
67
+ createElement(Form, { action: target(), ...props, fetcher: store }),
68
+ load: (url?: string) => store.load(url ?? target()),
69
+ submit: (body: FormData, options?: FetcherSubmitOptions) =>
70
+ store.submit(options?.action ?? target(), body, options),
71
+ };
72
+ }, [router, path]);
73
+
74
+ useEffect(() => fetcher.dispose, [fetcher]);
75
+
76
+ const state = useSyncExternalStore(
77
+ fetcher.subscribe,
78
+ fetcher.getSnapshot,
79
+ fetcher.getSnapshot,
80
+ );
81
+
82
+ return { ...fetcher, ...state };
83
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Preact components and typed data hooks. Import from @kasperrt/smolter/preact.
3
+ * @module
4
+ */
5
+
6
+ import type { Fetcher, SubmitOptions } from "../core/index.ts";
7
+ import { internalURL } from "../core/index.ts";
8
+ import { useRouter } from "./context.ts";
9
+ import type { FormHTMLAttributes, ReactNode } from "./runtime.ts";
10
+ import { createElement } from "./runtime.ts";
11
+
12
+ /** Form attributes with GET loader requests or POST, PUT, PATCH, and DELETE actions. */
13
+ export interface FormProps
14
+ extends Omit<FormHTMLAttributes<HTMLFormElement>, "action" | "method"> {
15
+ action?: string;
16
+ method?: "get" | "post" | "put" | "patch" | "delete";
17
+ }
18
+
19
+ type FormSubmitEvent = Parameters<NonNullable<FormProps["onSubmit"]>>[0];
20
+
21
+ interface InternalFormProps extends FormProps {
22
+ fetcher?: Fetcher;
23
+ }
24
+
25
+ /** Submit form data to a registered route without a document reload. */
26
+ export function Form({
27
+ action,
28
+ method = "post",
29
+ fetcher,
30
+ onSubmit,
31
+ ...props
32
+ }: InternalFormProps): ReactNode {
33
+ const router = useRouter();
34
+ const submit = (event: FormSubmitEvent) => {
35
+ onSubmit?.(event);
36
+
37
+ if (event.defaultPrevented) return;
38
+
39
+ const submitter = (event as SubmitEvent).submitter;
40
+ const target = internalURL(
41
+ router.getSnapshot().url,
42
+ submitter?.getAttribute("formaction") ?? action,
43
+ );
44
+
45
+ if (!target) return;
46
+ event.preventDefault();
47
+
48
+ const data = new FormData(event.currentTarget, submitter);
49
+ const verb = (
50
+ submitter?.getAttribute("formmethod") ?? method
51
+ ).toUpperCase();
52
+
53
+ if (verb === "GET") {
54
+ const search = new URLSearchParams();
55
+
56
+ for (const [key, value] of data)
57
+ search.append(key, typeof value === "string" ? value : value.name);
58
+
59
+ target.search = String(search);
60
+
61
+ void (fetcher ? fetcher.load : router.navigate)(target.href);
62
+
63
+ return;
64
+ }
65
+
66
+ const options: SubmitOptions = {
67
+ method: verb as SubmitOptions["method"],
68
+ };
69
+
70
+ void (fetcher ?? router).submit(target.href, data, options);
71
+ };
72
+
73
+ return createElement("form", {
74
+ ...props,
75
+ action,
76
+ method: method === "get" ? "get" : "post",
77
+ onSubmit: submit,
78
+ });
79
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Preact components and typed data hooks. Import from @kasperrt/smolter/preact.
3
+ * @module
4
+ */
5
+
6
+ import type {
7
+ AnyDataFunction,
8
+ DataOf,
9
+ Route,
10
+ RouteModule,
11
+ Router,
12
+ Snapshot,
13
+ } from "../core/index.ts";
14
+
15
+ import { useRouter, useSnapshot } from "./context.ts";
16
+
17
+ /** A route module or loader function used for data inference. */
18
+ export type LoaderSource = RouteModule | AnyDataFunction;
19
+
20
+ /** Infer server and client loader data from a route module or function. */
21
+ export type LoaderData<T> = T extends AnyDataFunction
22
+ ? DataOf<T>
23
+ : T extends RouteModule
24
+ ? DataOf<Extract<T["loader"] | T["clientLoader"], AnyDataFunction>>
25
+ : unknown;
26
+
27
+ /** Infer server and client action data from a route module or function. */
28
+ export type ActionData<T> = T extends AnyDataFunction
29
+ ? DataOf<T>
30
+ : T extends RouteModule
31
+ ? DataOf<Extract<T["action"] | T["clientAction"], AnyDataFunction>>
32
+ : unknown;
33
+ function useData(key: "data" | "actionData", route?: LoaderSource): unknown {
34
+ const path = (route as Route | undefined)?.path;
35
+
36
+ return useSnapshot((snapshot) => {
37
+ if (path !== undefined && path !== snapshot.path) return undefined;
38
+
39
+ return snapshot[key];
40
+ });
41
+ }
42
+ /** Pass a route for inference, or use useLoaderData<typeof loader>() in its module. */
43
+ export function useLoaderData<T extends LoaderSource = RouteModule>(
44
+ route?: T,
45
+ ): LoaderData<T> | undefined {
46
+ return useData("data", route) as LoaderData<T> | undefined;
47
+ }
48
+
49
+ /** Read the last page action result, optionally inferring its type from a route. */
50
+ export function useActionData<T extends LoaderSource = RouteModule>(
51
+ route?: T,
52
+ ): ActionData<T> | undefined {
53
+ return useData("actionData", route) as ActionData<T> | undefined;
54
+ }
55
+
56
+ /** Read path parameters for the active route. */
57
+ export function useParams(): Snapshot["params"] {
58
+ return useSnapshot((snapshot) => snapshot.params);
59
+ }
60
+
61
+ /** Read the current path, search, and hash together with the navigate function. */
62
+ export function useLocation(): readonly [string, Router["navigate"]] {
63
+ const router = useRouter();
64
+ const url = useSnapshot((snapshot) => snapshot.url);
65
+
66
+ return [url.replace(/^\w+:\/\/[^/]+/, ""), router.navigate];
67
+ }
68
+
69
+ /** Get the function that revalidates the current loader subject to shouldRevalidate. */
70
+ export function useRevalidator(): Router["revalidate"] {
71
+ return useRouter().revalidate;
72
+ }
@@ -0,0 +1,13 @@
1
+ /** Framework components and typed data hooks. @module */
2
+ export type * from "../core/index.ts";
3
+ export { createRouter, defineRoute } from "../core/index.ts";
4
+ export {
5
+ RouterProvider,
6
+ type RouterProviderProps,
7
+ useRouter,
8
+ } from "./context.ts";
9
+ export * from "./fetcher.ts";
10
+ export * from "./form.ts";
11
+ export * from "./hooks.ts";
12
+ export * from "./link.ts";
13
+ export * from "./routes.ts";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Preact components and typed data hooks. Import from @kasperrt/smolter/preact.
3
+ * @module
4
+ */
5
+
6
+ import { internalURL } from "../core/index.ts";
7
+ import { useRouter } from "./context.ts";
8
+ import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from "./runtime.ts";
9
+ import { createElement } from "./runtime.ts";
10
+
11
+ /** Anchor attributes with internal navigation and optional history replacement. */
12
+ export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
13
+ href: string;
14
+ replace?: boolean;
15
+ }
16
+
17
+ /** Render an anchor that handles internal navigation and preserves native modified clicks. */
18
+ export function Link({
19
+ href,
20
+ replace,
21
+ onClick,
22
+ ...props
23
+ }: LinkProps): ReactNode {
24
+ const router = useRouter();
25
+ const click = (event: MouseEvent<HTMLAnchorElement>) => {
26
+ onClick?.(event);
27
+
28
+ if (
29
+ event.defaultPrevented ||
30
+ event.button ||
31
+ event.metaKey ||
32
+ event.ctrlKey ||
33
+ event.altKey ||
34
+ event.shiftKey ||
35
+ props.download !== undefined ||
36
+ (props.target && props.target !== "_self")
37
+ )
38
+ return;
39
+
40
+ if (!internalURL(router.getSnapshot().url, href)) return;
41
+ event.preventDefault();
42
+ void router.navigate(href, { replace });
43
+ };
44
+
45
+ return createElement("a", { ...props, href, onClick: click });
46
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Preact components and typed data hooks. Import from @kasperrt/smolter/preact.
3
+ * @module
4
+ */
5
+
6
+ import type { Route as RouteDefinition, RouteModule } from "../core/index.ts";
7
+ import { useRouter, useSnapshot } from "./context.ts";
8
+ import type { ComponentType, ReactNode } from "./runtime.ts";
9
+ import { createElement, useMemo } from "./runtime.ts";
10
+
11
+ /** Renderable route module with optional loading and error components. */
12
+ export interface ViewModule extends RouteModule {
13
+ default?: ComponentType;
14
+ HydrateFallback?: ComponentType;
15
+ ErrorBoundary?: ComponentType<ErrorProps>;
16
+ }
17
+
18
+ /** Normalized route error supplied to an ErrorBoundary component. */
19
+ export interface ErrorProps {
20
+ error: Error;
21
+ }
22
+
23
+ /** Fallback content used when no matching component can be rendered. */
24
+ export interface RoutesProps {
25
+ fallback?: ReactNode;
26
+ }
27
+ /** Renders the first matching registered module. */
28
+ export function Routes({ fallback = null }: RoutesProps): ReactNode {
29
+ const router = useRouter();
30
+ const state = useSnapshot((snapshot) => snapshot);
31
+ const module = router.routes.find((route) => route.path === state.path) as
32
+ | RouteDefinition<ViewModule>
33
+ | undefined;
34
+ const component = state.error
35
+ ? module?.ErrorBoundary
36
+ : state.state === "loading" && !state.loaded
37
+ ? module?.HydrateFallback
38
+ : module?.default;
39
+
40
+ const element = useMemo(
41
+ () =>
42
+ component &&
43
+ createElement(component as ComponentType<ErrorProps>, {
44
+ error: state.error!,
45
+ }),
46
+ [component, state.error],
47
+ );
48
+
49
+ return element ?? state.error?.message ?? fallback;
50
+ }
@@ -0,0 +1,15 @@
1
+ export type {
2
+ AnchorHTMLAttributes,
3
+ ComponentType,
4
+ FormHTMLAttributes,
5
+ MouseEvent,
6
+ ReactNode,
7
+ } from "preact/compat";
8
+ export {
9
+ createContext,
10
+ createElement,
11
+ useContext,
12
+ useEffect,
13
+ useMemo,
14
+ useSyncExternalStore,
15
+ } from "preact/compat";