@tschk/moonshine-tanstack 0.3.4
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 +73 -0
- package/package.json +42 -0
- package/src/aliases.ts +53 -0
- package/src/client.ts +12 -0
- package/src/index.ts +41 -0
- package/src/link.ts +184 -0
- package/src/location.ts +117 -0
- package/src/navigation.ts +166 -0
- package/src/outlet.ts +33 -0
- package/src/server.ts +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# `@tschk/moonshine-tanstack`
|
|
2
|
+
|
|
3
|
+
The **runtime** client API of TanStack Router / TanStack Start reimplemented on
|
|
4
|
+
moonshine. It imports no TanStack code and lists none as a dependency:
|
|
5
|
+
`Link`, `useNavigate`, `useRouterState` and friends are ordinary React
|
|
6
|
+
components written against `@tschk/moonshine/router`.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// bundler alias
|
|
10
|
+
"@tanstack/react-router" -> "@tschk/moonshine-tanstack";
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import {
|
|
15
|
+
Link,
|
|
16
|
+
Outlet,
|
|
17
|
+
useNavigate,
|
|
18
|
+
useSearch,
|
|
19
|
+
} from "@tschk/moonshine-tanstack";
|
|
20
|
+
import { json, createServerRoute } from "@tschk/moonshine-tanstack/server";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Subpath | Contents |
|
|
24
|
+
| -------------- | ----------------------------------------------- |
|
|
25
|
+
| `.` | client API (components + hooks + search codecs) |
|
|
26
|
+
| `./link` | `Link`, `Navigate` |
|
|
27
|
+
| `./navigation` | `useNavigate`, `useRouter`, `useRouterState`, … |
|
|
28
|
+
| `./outlet` | `Outlet`, `OutletProvider` |
|
|
29
|
+
| `./server` | `json`, `createServerRoute`, moonshine server |
|
|
30
|
+
| `./client` | moonshine signals + resources |
|
|
31
|
+
|
|
32
|
+
## Supported
|
|
33
|
+
|
|
34
|
+
- `Link` — `to` with `$param` and `$` splat interpolation, `params`, `search`
|
|
35
|
+
(record or updater), `hash`, `replace`, `resetScroll`, `preload`
|
|
36
|
+
(`"intent"` hover / `"viewport"` / `"render"`), `disabled`,
|
|
37
|
+
`activeProps`/`inactiveProps`, `className` and `children` render callbacks,
|
|
38
|
+
`data-status="active"` and `aria-current`. Navigation runs inside
|
|
39
|
+
`startTransition`.
|
|
40
|
+
- `Navigate` — declarative redirect on mount.
|
|
41
|
+
- `Outlet` / `OutletProvider`.
|
|
42
|
+
- `useNavigate`, `useRouter`, `useRouterState` (with `select`), `useLocation`,
|
|
43
|
+
`useParams`, `useSearch` (with `select`), `useCanGoBack`.
|
|
44
|
+
- `redirect`, `notFound`, `isRedirect`, `isNotFound` — same "return an error you
|
|
45
|
+
throw" contract as TanStack.
|
|
46
|
+
- Search codecs: `parseSearch` / `stringifySearch` with TanStack's JSON value
|
|
47
|
+
encoding, plus `buildHref` and `interpolatePath`.
|
|
48
|
+
|
|
49
|
+
## Not supported
|
|
50
|
+
|
|
51
|
+
These require the TanStack Vite plugin, the generated route tree, or TanStack's
|
|
52
|
+
own loader/RPC runtime, and are **not** provided:
|
|
53
|
+
|
|
54
|
+
- **Route definition and typing**: `createFileRoute`, `createRootRoute`,
|
|
55
|
+
`createRoute`, `createRouter`, `RouterProvider`, `routeTree.gen.ts`, and the
|
|
56
|
+
whole type-level route registry. Every `to`, `from` and `params` here is a
|
|
57
|
+
plain string/record with no route-tree inference — `from` is accepted and
|
|
58
|
+
ignored, `strict` has no effect.
|
|
59
|
+
- **Data loading**: `loader`, `useLoaderData`, `useLoaderDeps`, `beforeLoad`,
|
|
60
|
+
`validateSearch`, `router.invalidate()` (a no-op here), `useMatch`,
|
|
61
|
+
`useMatches`, `useMatchRoute`, `Await`, `defer`, pending/error components.
|
|
62
|
+
- **Server functions**: `createServerFn`, `createMiddleware`,
|
|
63
|
+
`createStartHandler` — these depend on the build plugin's client/server
|
|
64
|
+
splitting. Use `createServerRoute` on a moonshine route instead.
|
|
65
|
+
- **Transitions/blocking**: `useBlocker`, `Block`, `isTransitioning`
|
|
66
|
+
(always `false`), `useRouterState().status` (always `"idle"`),
|
|
67
|
+
`matches` (always empty).
|
|
68
|
+
- **History state**: `location.state` is always `{}`; there is no
|
|
69
|
+
`useHistoryState`.
|
|
70
|
+
- `useSearch` reads `window.location.search`; on the server it is empty because
|
|
71
|
+
moonshine's router signal carries the pathname only.
|
|
72
|
+
|
|
73
|
+
See [Moonshine README](../../README.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tschk/moonshine-tanstack",
|
|
3
|
+
"version": "0.3.4",
|
|
4
|
+
"description": "TanStack Router / Start client API reimplemented on moonshine — drop @tanstack/react-router, keep the imports",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./client": "./src/client.ts",
|
|
12
|
+
"./server": "./src/server.ts",
|
|
13
|
+
"./navigation": "./src/navigation.ts",
|
|
14
|
+
"./link": "./src/link.ts",
|
|
15
|
+
"./outlet": "./src/outlet.ts",
|
|
16
|
+
"./aliases": "./src/aliases.ts"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@tschk/moonshine": "^0.3.4",
|
|
20
|
+
"@tschk/moonshine-router": "^0.3.4"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": ">=18",
|
|
24
|
+
"react-dom": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"moonshine",
|
|
32
|
+
"tanstack",
|
|
33
|
+
"router",
|
|
34
|
+
"adapter"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"format": "bunx prettier --check .",
|
|
38
|
+
"lint": "bunx oxlint .",
|
|
39
|
+
"typecheck": "bunx tsc --noEmit",
|
|
40
|
+
"test": "bun test"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/aliases.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop-in mapping from the TanStack Router specifiers onto this package.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { moonshineTanstackPlugin } from "@tschk/moonshine-tanstack/aliases";
|
|
6
|
+
* await Bun.build({ entrypoints: ["./app.tsx"], plugins: [moonshineTanstackPlugin()] });
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { BunPlugin } from "bun";
|
|
11
|
+
|
|
12
|
+
/** Bare specifier each replaced package is served by. */
|
|
13
|
+
export const tanstackAliases: Readonly<Record<string, string>> = {
|
|
14
|
+
"@tanstack/react-router": "@tschk/moonshine-tanstack",
|
|
15
|
+
"@tanstack/router-core": "@tschk/moonshine-tanstack",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** File in this package backing each alias, relative to `src/`. */
|
|
19
|
+
const files: Readonly<Record<string, string>> = {
|
|
20
|
+
"@tanstack/react-router": "index.ts",
|
|
21
|
+
"@tanstack/router-core": "index.ts",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `compilerOptions.paths` block for an app's tsconfig, so the type checker
|
|
26
|
+
* follows the same mapping the bundler applies.
|
|
27
|
+
*/
|
|
28
|
+
export function tsconfigPaths(): Record<string, string[]> {
|
|
29
|
+
return Object.fromEntries(
|
|
30
|
+
Object.entries(tanstackAliases).map(([from, to]) => [from, [to]]),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Absolute path this package serves `specifier` from, if it is aliased. */
|
|
35
|
+
export function resolveAlias(specifier: string): string | undefined {
|
|
36
|
+
const file = files[specifier];
|
|
37
|
+
return file === undefined ? undefined : join(import.meta.dir, file);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const filter = /^@tanstack\/(react-router|router-core)$/;
|
|
41
|
+
|
|
42
|
+
/** Bun bundler/runtime plugin rewriting TanStack imports to this package. */
|
|
43
|
+
export function moonshineTanstackPlugin(): BunPlugin {
|
|
44
|
+
return {
|
|
45
|
+
name: "moonshine-tanstack-aliases",
|
|
46
|
+
setup(build) {
|
|
47
|
+
build.onResolve({ filter }, (args) => {
|
|
48
|
+
const path = resolveAlias(args.path);
|
|
49
|
+
return path === undefined ? undefined : { path };
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/client.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @tschk/moonshine-tanstack
|
|
5
|
+
*
|
|
6
|
+
* The TanStack Router / Start client API reimplemented on moonshine's router.
|
|
7
|
+
* Alias `@tanstack/react-router` to this package to drop the dependency.
|
|
8
|
+
*
|
|
9
|
+
* Route-tree generation (`createFileRoute`, `routeTree.gen.ts`) is not provided
|
|
10
|
+
* — it requires the TanStack Vite plugin. See the README.
|
|
11
|
+
*/
|
|
12
|
+
export { Link, Navigate } from "./link";
|
|
13
|
+
export type { LinkProps, LinkRenderState, NavigateProps } from "./link";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
NotFound,
|
|
17
|
+
Redirect,
|
|
18
|
+
isNotFound,
|
|
19
|
+
isRedirect,
|
|
20
|
+
notFound,
|
|
21
|
+
redirect,
|
|
22
|
+
useCanGoBack,
|
|
23
|
+
useLocation,
|
|
24
|
+
useNavigate,
|
|
25
|
+
useParams,
|
|
26
|
+
useRouter,
|
|
27
|
+
useRouterState,
|
|
28
|
+
useSearch,
|
|
29
|
+
} from "./navigation";
|
|
30
|
+
export type { NavigateFn, RouterInstance, RouterState } from "./navigation";
|
|
31
|
+
|
|
32
|
+
export { Outlet, OutletProvider } from "./outlet";
|
|
33
|
+
export type { OutletProviderProps } from "./outlet";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
buildHref,
|
|
37
|
+
interpolatePath,
|
|
38
|
+
parseSearch,
|
|
39
|
+
stringifySearch,
|
|
40
|
+
} from "./location";
|
|
41
|
+
export type { ParsedLocation, SearchRecord, ToOptions } from "./location";
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TanStack Router's `<Link>` on the moonshine router.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* import { Link } from "@tschk/moonshine-tanstack";
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
createElement,
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useRef,
|
|
15
|
+
useTransition,
|
|
16
|
+
type AnchorHTMLAttributes,
|
|
17
|
+
type MouseEvent,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
} from "react";
|
|
20
|
+
import { navigate } from "@tschk/moonshine/router";
|
|
21
|
+
import { buildHref, type ToOptions } from "./location";
|
|
22
|
+
import { useParsedLocation } from "./location";
|
|
23
|
+
|
|
24
|
+
export type LinkRenderState = { isActive: boolean; isTransitioning: boolean };
|
|
25
|
+
|
|
26
|
+
export type LinkProps = Omit<
|
|
27
|
+
AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
28
|
+
"href" | "className" | "style" | "children"
|
|
29
|
+
> &
|
|
30
|
+
ToOptions & {
|
|
31
|
+
activeOptions?: { exact?: boolean; includeHash?: boolean };
|
|
32
|
+
/** `"intent"` warms the URL on hover, `"viewport"` when it scrolls in. */
|
|
33
|
+
preload?: false | "intent" | "viewport" | "render";
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
activeProps?: Record<string, unknown>;
|
|
36
|
+
inactiveProps?: Record<string, unknown>;
|
|
37
|
+
className?: string | ((state: LinkRenderState) => string | undefined);
|
|
38
|
+
style?: Record<string, unknown>;
|
|
39
|
+
children?: ReactNode | ((state: LinkRenderState) => ReactNode);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function isExternal(href: string, target?: string): boolean {
|
|
43
|
+
if (target && target !== "_self") return true;
|
|
44
|
+
return (
|
|
45
|
+
/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) ||
|
|
46
|
+
href.startsWith("//") ||
|
|
47
|
+
href.startsWith("#")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const preloaded = new Set<string>();
|
|
52
|
+
|
|
53
|
+
function preloadOnce(url: string): void {
|
|
54
|
+
if (preloaded.has(url) || typeof document === "undefined") return;
|
|
55
|
+
preloaded.add(url);
|
|
56
|
+
const tag = document.createElement("link");
|
|
57
|
+
tag.rel = "prefetch";
|
|
58
|
+
tag.href = url;
|
|
59
|
+
document.head.append(tag);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pathnameOf(href: string): string {
|
|
63
|
+
const cut = href.search(/[?#]/);
|
|
64
|
+
return cut === -1 ? href : href.slice(0, cut);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function Link({
|
|
68
|
+
to,
|
|
69
|
+
from: _from,
|
|
70
|
+
params,
|
|
71
|
+
search,
|
|
72
|
+
hash,
|
|
73
|
+
replace,
|
|
74
|
+
resetScroll,
|
|
75
|
+
activeOptions,
|
|
76
|
+
preload = "intent",
|
|
77
|
+
disabled,
|
|
78
|
+
activeProps,
|
|
79
|
+
inactiveProps,
|
|
80
|
+
className,
|
|
81
|
+
style,
|
|
82
|
+
children,
|
|
83
|
+
onClick,
|
|
84
|
+
onMouseEnter,
|
|
85
|
+
...rest
|
|
86
|
+
}: LinkProps): ReactNode {
|
|
87
|
+
const location = useParsedLocation();
|
|
88
|
+
const href = buildHref({ to, params, search, hash }, location.search);
|
|
89
|
+
const external = isExternal(href, rest.target);
|
|
90
|
+
const ref = useRef<HTMLAnchorElement>(null);
|
|
91
|
+
// Navigation runs in a transition so a slow subtree cannot block the click.
|
|
92
|
+
const [, startTransition] = useTransition();
|
|
93
|
+
const inert = external || disabled === true || preload === false;
|
|
94
|
+
|
|
95
|
+
const warm = useCallback(() => {
|
|
96
|
+
if (!inert) preloadOnce(href);
|
|
97
|
+
}, [inert, href]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (inert) return;
|
|
101
|
+
if (preload === "render") {
|
|
102
|
+
warm();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const node = ref.current;
|
|
106
|
+
if (
|
|
107
|
+
!node ||
|
|
108
|
+
preload !== "viewport" ||
|
|
109
|
+
typeof IntersectionObserver === "undefined"
|
|
110
|
+
) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const observer = new IntersectionObserver((entries) => {
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (entry.isIntersecting) {
|
|
116
|
+
warm();
|
|
117
|
+
observer.disconnect();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
observer.observe(node);
|
|
122
|
+
return () => observer.disconnect();
|
|
123
|
+
}, [warm, preload, inert]);
|
|
124
|
+
|
|
125
|
+
const target = pathnameOf(href);
|
|
126
|
+
const isActive = activeOptions?.exact
|
|
127
|
+
? location.pathname === target
|
|
128
|
+
: location.pathname === target ||
|
|
129
|
+
location.pathname.startsWith(
|
|
130
|
+
target.endsWith("/") ? target : `${target}/`,
|
|
131
|
+
);
|
|
132
|
+
const state: LinkRenderState = { isActive, isTransitioning: false };
|
|
133
|
+
|
|
134
|
+
return createElement(
|
|
135
|
+
"a",
|
|
136
|
+
{
|
|
137
|
+
...rest,
|
|
138
|
+
...(isActive ? activeProps : inactiveProps),
|
|
139
|
+
ref,
|
|
140
|
+
href,
|
|
141
|
+
style,
|
|
142
|
+
className: typeof className === "function" ? className(state) : className,
|
|
143
|
+
"data-status": isActive ? "active" : undefined,
|
|
144
|
+
"aria-current": isActive ? "page" : undefined,
|
|
145
|
+
onMouseEnter: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
146
|
+
onMouseEnter?.(event);
|
|
147
|
+
if (preload === "intent") warm();
|
|
148
|
+
},
|
|
149
|
+
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
150
|
+
onClick?.(event);
|
|
151
|
+
if (
|
|
152
|
+
event.defaultPrevented ||
|
|
153
|
+
event.button !== 0 ||
|
|
154
|
+
event.metaKey ||
|
|
155
|
+
event.altKey ||
|
|
156
|
+
event.ctrlKey ||
|
|
157
|
+
event.shiftKey ||
|
|
158
|
+
external ||
|
|
159
|
+
disabled === true
|
|
160
|
+
) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
startTransition(() => {
|
|
165
|
+
navigate(href, { replace });
|
|
166
|
+
if (resetScroll !== false) globalThis.scrollTo?.(0, 0);
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
typeof children === "function" ? children(state) : children,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type NavigateProps = ToOptions;
|
|
175
|
+
|
|
176
|
+
/** Redirects on mount. */
|
|
177
|
+
export function Navigate(props: NavigateProps): null {
|
|
178
|
+
const location = useParsedLocation();
|
|
179
|
+
const href = buildHref(props, location.search);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
navigate(href, { replace: props.replace });
|
|
182
|
+
}, [href, props.replace]);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
package/src/location.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TanStack Router's location shape, derived from the moonshine router signal.
|
|
5
|
+
*
|
|
6
|
+
* Moonshine's signal carries the pathname only, so `search` and `hash` are read
|
|
7
|
+
* back from `window.location` on each render.
|
|
8
|
+
*/
|
|
9
|
+
import { useMemo, useReducer } from "react";
|
|
10
|
+
import { useLocation as useMoonshineLocation } from "@tschk/moonshine/router";
|
|
11
|
+
|
|
12
|
+
export type SearchRecord = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
export type ParsedLocation = {
|
|
15
|
+
href: string;
|
|
16
|
+
pathname: string;
|
|
17
|
+
search: SearchRecord;
|
|
18
|
+
searchStr: string;
|
|
19
|
+
hash: string;
|
|
20
|
+
state: Record<string, unknown>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ToOptions = {
|
|
24
|
+
to?: string;
|
|
25
|
+
from?: string;
|
|
26
|
+
params?: Record<string, string>;
|
|
27
|
+
search?: SearchRecord | ((current: SearchRecord) => SearchRecord);
|
|
28
|
+
hash?: string;
|
|
29
|
+
replace?: boolean;
|
|
30
|
+
resetScroll?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** `parseSearch` — TanStack decodes JSON values inside query params. */
|
|
34
|
+
export function parseSearch(searchStr: string): SearchRecord {
|
|
35
|
+
const out: SearchRecord = {};
|
|
36
|
+
for (const [key, value] of new URLSearchParams(searchStr)) {
|
|
37
|
+
try {
|
|
38
|
+
out[key] = JSON.parse(value) as unknown;
|
|
39
|
+
} catch {
|
|
40
|
+
out[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `stringifySearch` — the inverse of {@link parseSearch}. */
|
|
47
|
+
export function stringifySearch(search: SearchRecord): string {
|
|
48
|
+
const params = new URLSearchParams();
|
|
49
|
+
for (const [key, value] of Object.entries(search)) {
|
|
50
|
+
if (value === undefined) continue;
|
|
51
|
+
params.set(key, typeof value === "string" ? value : JSON.stringify(value));
|
|
52
|
+
}
|
|
53
|
+
const query = params.toString();
|
|
54
|
+
return query ? `?${query}` : "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fills `$param` / `$` placeholders in a `to` template. */
|
|
58
|
+
export function interpolatePath(
|
|
59
|
+
to: string,
|
|
60
|
+
params: Record<string, string> = {},
|
|
61
|
+
): string {
|
|
62
|
+
return to
|
|
63
|
+
.split("/")
|
|
64
|
+
.map((segment) => {
|
|
65
|
+
if (segment === "$") return params["*"] ?? params._splat ?? "";
|
|
66
|
+
if (segment.startsWith("$")) return params[segment.slice(1)] ?? segment;
|
|
67
|
+
return segment;
|
|
68
|
+
})
|
|
69
|
+
.join("/");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Builds the final URL string for a `ToOptions` navigation descriptor. */
|
|
73
|
+
export function buildHref(
|
|
74
|
+
options: ToOptions,
|
|
75
|
+
currentSearch: SearchRecord = {},
|
|
76
|
+
): string {
|
|
77
|
+
const pathname = interpolatePath(options.to ?? "", options.params);
|
|
78
|
+
const search =
|
|
79
|
+
typeof options.search === "function"
|
|
80
|
+
? options.search(currentSearch)
|
|
81
|
+
: (options.search ?? {});
|
|
82
|
+
const hash = options.hash
|
|
83
|
+
? options.hash.startsWith("#")
|
|
84
|
+
? options.hash
|
|
85
|
+
: `#${options.hash}`
|
|
86
|
+
: "";
|
|
87
|
+
return `${pathname}${stringifySearch(search)}${hash}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function browserPart(part: "search" | "hash"): string {
|
|
91
|
+
if (typeof window === "undefined") return "";
|
|
92
|
+
return window.location[part];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Forces a re-render; used after navigations that only change the query. */
|
|
96
|
+
export function useRerender(): () => void {
|
|
97
|
+
const [, bump] = useReducer((n: number) => n + 1, 0);
|
|
98
|
+
return bump;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function useParsedLocation(): ParsedLocation {
|
|
102
|
+
const pathname = useMoonshineLocation();
|
|
103
|
+
const searchStr = browserPart("search");
|
|
104
|
+
const hash = browserPart("hash");
|
|
105
|
+
return useMemo(
|
|
106
|
+
() => ({
|
|
107
|
+
href: `${pathname}${searchStr}${hash}`,
|
|
108
|
+
pathname,
|
|
109
|
+
search: parseSearch(searchStr),
|
|
110
|
+
searchStr,
|
|
111
|
+
hash,
|
|
112
|
+
// History state is not tracked by the moonshine router.
|
|
113
|
+
state: {},
|
|
114
|
+
}),
|
|
115
|
+
[pathname, searchStr, hash],
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TanStack Router hooks implemented on the moonshine router.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { useNavigate, useRouterState } from "@tschk/moonshine-tanstack";
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useMemo } from "react";
|
|
11
|
+
import {
|
|
12
|
+
navigate,
|
|
13
|
+
useParams as useMoonshineParams,
|
|
14
|
+
} from "@tschk/moonshine/router";
|
|
15
|
+
import {
|
|
16
|
+
buildHref,
|
|
17
|
+
useParsedLocation,
|
|
18
|
+
useRerender,
|
|
19
|
+
type ParsedLocation,
|
|
20
|
+
type SearchRecord,
|
|
21
|
+
type ToOptions,
|
|
22
|
+
} from "./location";
|
|
23
|
+
|
|
24
|
+
export type NavigateFn = (options: ToOptions) => void;
|
|
25
|
+
|
|
26
|
+
export type RouterInstance = {
|
|
27
|
+
navigate: NavigateFn;
|
|
28
|
+
buildLocation: (options: ToOptions) => { href: string };
|
|
29
|
+
history: {
|
|
30
|
+
back(): void;
|
|
31
|
+
forward(): void;
|
|
32
|
+
go(delta: number): void;
|
|
33
|
+
};
|
|
34
|
+
invalidate(): void;
|
|
35
|
+
state: { location: ParsedLocation };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function useNavigate(): NavigateFn {
|
|
39
|
+
const location = useParsedLocation();
|
|
40
|
+
const rerender = useRerender();
|
|
41
|
+
return useCallback(
|
|
42
|
+
(options: ToOptions) => {
|
|
43
|
+
const href = buildHref(
|
|
44
|
+
{ ...options, to: options.to ?? location.pathname },
|
|
45
|
+
location.search,
|
|
46
|
+
);
|
|
47
|
+
navigate(href, { replace: options.replace });
|
|
48
|
+
if (options.resetScroll !== false) globalThis.scrollTo?.(0, 0);
|
|
49
|
+
// A query-only navigation leaves the pathname signal unchanged.
|
|
50
|
+
rerender();
|
|
51
|
+
},
|
|
52
|
+
[location.pathname, location.search, rerender],
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useRouter(): RouterInstance {
|
|
57
|
+
const location = useParsedLocation();
|
|
58
|
+
const go = useNavigate();
|
|
59
|
+
return useMemo(
|
|
60
|
+
() => ({
|
|
61
|
+
navigate: go,
|
|
62
|
+
buildLocation: (options: ToOptions) => ({
|
|
63
|
+
href: buildHref(options, location.search),
|
|
64
|
+
}),
|
|
65
|
+
history: {
|
|
66
|
+
back: () => globalThis.history?.back(),
|
|
67
|
+
forward: () => globalThis.history?.forward(),
|
|
68
|
+
go: (delta: number) => globalThis.history?.go(delta),
|
|
69
|
+
},
|
|
70
|
+
// Moonshine re-renders from its signal graph; there is nothing to
|
|
71
|
+
// invalidate.
|
|
72
|
+
invalidate: () => {},
|
|
73
|
+
state: { location },
|
|
74
|
+
}),
|
|
75
|
+
[go, location],
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type RouterState = {
|
|
80
|
+
location: ParsedLocation;
|
|
81
|
+
status: "idle";
|
|
82
|
+
isLoading: false;
|
|
83
|
+
isTransitioning: false;
|
|
84
|
+
matches: never[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function useRouterState<T = RouterState>(options?: {
|
|
88
|
+
select?: (state: RouterState) => T;
|
|
89
|
+
}): T {
|
|
90
|
+
const location = useParsedLocation();
|
|
91
|
+
const state = useMemo<RouterState>(
|
|
92
|
+
() => ({
|
|
93
|
+
location,
|
|
94
|
+
status: "idle",
|
|
95
|
+
isLoading: false,
|
|
96
|
+
isTransitioning: false,
|
|
97
|
+
matches: [],
|
|
98
|
+
}),
|
|
99
|
+
[location],
|
|
100
|
+
);
|
|
101
|
+
return (options?.select ? options.select(state) : state) as T;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function useLocation(): ParsedLocation {
|
|
105
|
+
return useParsedLocation();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function useParams<
|
|
109
|
+
T extends Record<string, string> = Record<string, string>,
|
|
110
|
+
>(_options?: { from?: string; strict?: boolean }): T {
|
|
111
|
+
return useMoonshineParams() as T;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function useSearch<T extends SearchRecord = SearchRecord>(_options?: {
|
|
115
|
+
from?: string;
|
|
116
|
+
strict?: boolean;
|
|
117
|
+
select?: (search: SearchRecord) => T;
|
|
118
|
+
}): T {
|
|
119
|
+
const { search } = useParsedLocation();
|
|
120
|
+
return (_options?.select ? _options.select(search) : search) as T;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function useCanGoBack(): boolean {
|
|
124
|
+
return (globalThis.history?.length ?? 0) > 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Thrown to unwind rendering, matching TanStack's control-flow contract. */
|
|
128
|
+
export class Redirect extends Error {
|
|
129
|
+
constructor(readonly options: ToOptions & { statusCode?: number }) {
|
|
130
|
+
super(`redirect: ${options.to ?? ""}`);
|
|
131
|
+
this.name = "Redirect";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class NotFound extends Error {
|
|
136
|
+
constructor(readonly data?: unknown) {
|
|
137
|
+
super("not found");
|
|
138
|
+
this.name = "NotFound";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Returns the error (TanStack's contract is `throw redirect({ to })`). */
|
|
143
|
+
export function redirect(
|
|
144
|
+
options: ToOptions & { statusCode?: number; throw?: boolean },
|
|
145
|
+
): Redirect {
|
|
146
|
+
const error = new Redirect(options);
|
|
147
|
+
if (options.throw === true) throw error;
|
|
148
|
+
return error;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function notFound(options?: {
|
|
152
|
+
data?: unknown;
|
|
153
|
+
throw?: boolean;
|
|
154
|
+
}): NotFound {
|
|
155
|
+
const error = new NotFound(options?.data);
|
|
156
|
+
if (options?.throw === true) throw error;
|
|
157
|
+
return error;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isRedirect(value: unknown): value is Redirect {
|
|
161
|
+
return value instanceof Redirect;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isNotFound(value: unknown): value is NotFound {
|
|
165
|
+
return value instanceof NotFound;
|
|
166
|
+
}
|
package/src/outlet.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<Outlet>` for TanStack-style nested layouts.
|
|
5
|
+
*
|
|
6
|
+
* Moonshine matches a flat route table, so the child element is supplied
|
|
7
|
+
* explicitly by the layout rather than derived from a generated route tree.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
createContext,
|
|
11
|
+
createElement,
|
|
12
|
+
useContext,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from "react";
|
|
15
|
+
|
|
16
|
+
const OutletContext = createContext<ReactNode>(null);
|
|
17
|
+
|
|
18
|
+
export type OutletProviderProps = {
|
|
19
|
+
/** The child route element rendered where `<Outlet />` appears. */
|
|
20
|
+
value: ReactNode;
|
|
21
|
+
children?: ReactNode;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function OutletProvider({
|
|
25
|
+
value,
|
|
26
|
+
children,
|
|
27
|
+
}: OutletProviderProps): ReactNode {
|
|
28
|
+
return createElement(OutletContext.Provider, { value }, children);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Outlet(): ReactNode {
|
|
32
|
+
return useContext(OutletContext);
|
|
33
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server surface for TanStack Start apps on moonshine: response helpers plus
|
|
3
|
+
* moonshine's request handling.
|
|
4
|
+
*
|
|
5
|
+
* `createServerFn` is deliberately absent — see the README.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
createMoonshineServer,
|
|
9
|
+
definePage,
|
|
10
|
+
handleMoonshineRequest,
|
|
11
|
+
resolvePage,
|
|
12
|
+
toMoonshineRequest,
|
|
13
|
+
} from "@tschk/moonshine/server";
|
|
14
|
+
export type {
|
|
15
|
+
MoonshinePageModule,
|
|
16
|
+
MoonshineRequest,
|
|
17
|
+
MoonshineServer,
|
|
18
|
+
} from "@tschk/moonshine/server";
|
|
19
|
+
|
|
20
|
+
/** `json()` from `@tanstack/react-start`. */
|
|
21
|
+
export function json<T>(data: T, init: ResponseInit = {}): Response {
|
|
22
|
+
const headers = new Headers(init.headers);
|
|
23
|
+
if (!headers.has("content-type")) {
|
|
24
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
25
|
+
}
|
|
26
|
+
return new Response(JSON.stringify(data), { ...init, headers });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a server route handler.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* export const GET = createServerRoute(async (req) => json({ ok: true }));
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function createServerRoute(
|
|
37
|
+
handler: (request: Request) => Response | Promise<Response>,
|
|
38
|
+
): (request: Request) => Promise<Response> {
|
|
39
|
+
return async (request) => {
|
|
40
|
+
try {
|
|
41
|
+
return await handler(request);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
44
|
+
return json({ error: message }, { status: 500 });
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|