@tschk/moonshine-next 0.3.1 → 0.3.3
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 +125 -17
- package/package.json +9 -6
- package/src/aliases.ts +67 -0
- package/src/dynamic.ts +52 -2
- package/src/font/google.ts +116 -0
- package/src/headers.ts +60 -2
- package/src/image.ts +61 -5
- package/src/link.ts +154 -3
- package/src/navigation.ts +120 -57
- package/src/script.ts +33 -0
- package/src/shims.d.ts +0 -86
package/README.md
CHANGED
|
@@ -1,31 +1,139 @@
|
|
|
1
1
|
# `@tschk/moonshine-next`
|
|
2
2
|
|
|
3
|
-
Next.js
|
|
4
|
-
|
|
3
|
+
Moonshine **reimplements the Next.js API surface**. It does not wrap, re-export,
|
|
4
|
+
or depend on Next — `next` is not a dependency, a peer dependency, or a
|
|
5
|
+
devDependency of this package. You keep writing `next/link`, `next/navigation`,
|
|
6
|
+
`next/image`; moonshine provides them, on its own signal runtime and router.
|
|
5
7
|
|
|
6
8
|
```ts
|
|
7
9
|
import { createSignal, useSignal } from "@tschk/moonshine-next";
|
|
8
|
-
import { useRouter,
|
|
10
|
+
import { useRouter, usePathname } from "@tschk/moonshine-next/navigation";
|
|
9
11
|
import Link from "@tschk/moonshine-next/link";
|
|
10
12
|
import Image from "@tschk/moonshine-next/image";
|
|
11
|
-
import { headers } from "@tschk/moonshine-next/headers";
|
|
12
|
-
import { useFragmentShader } from "@tschk/moonshine-next/shaders";
|
|
13
|
+
import { headers, cookies } from "@tschk/moonshine-next/headers";
|
|
13
14
|
import { moonshineRoute, moonshineJson } from "@tschk/moonshine-next/server";
|
|
14
15
|
```
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
| ---------------- | -------------------------------------------------- |
|
|
18
|
-
| `.` / `./client` | moonshine signals + resources (`"use client"`) |
|
|
19
|
-
| `./navigation` | `next/navigation` + pathname/search signals |
|
|
20
|
-
| `./link` | `next/link` |
|
|
21
|
-
| `./image` | `next/image` |
|
|
22
|
-
| `./headers` | `next/headers` (server only) |
|
|
23
|
-
| `./dynamic` | `next/dynamic` |
|
|
24
|
-
| `./shaders` | moonshine WebGL helpers |
|
|
25
|
-
| `./server` | `moonshineRoute` / `Json` / `Html` + pages helpers |
|
|
17
|
+
Peers: `react` >= 18, `react-dom` >= 18. That is the whole runtime requirement.
|
|
26
18
|
|
|
27
|
-
|
|
19
|
+
## Drop-in: keep your existing imports
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+
You do not have to rewrite a single import. Map the `next/*` specifiers onto
|
|
22
|
+
this package at build time.
|
|
23
|
+
|
|
24
|
+
### Bun
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { moonshineNextPlugin } from "@tschk/moonshine-next/aliases";
|
|
28
|
+
|
|
29
|
+
await Bun.build({
|
|
30
|
+
entrypoints: ["./app/page.tsx"],
|
|
31
|
+
plugins: [moonshineNextPlugin()],
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For `bun run` / `bun test`, register it in a preload file:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// bun-preload.ts
|
|
39
|
+
import { plugin } from "bun";
|
|
40
|
+
import { moonshineNextPlugin } from "@tschk/moonshine-next/aliases";
|
|
41
|
+
|
|
42
|
+
plugin(moonshineNextPlugin());
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```toml
|
|
46
|
+
# bunfig.toml
|
|
47
|
+
preload = ["./bun-preload.ts"]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### TypeScript
|
|
51
|
+
|
|
52
|
+
So the type checker follows the same mapping the bundler applies:
|
|
53
|
+
|
|
54
|
+
```jsonc
|
|
55
|
+
{
|
|
56
|
+
"compilerOptions": {
|
|
57
|
+
"paths": {
|
|
58
|
+
"next/link": ["@tschk/moonshine-next/link"],
|
|
59
|
+
"next/navigation": ["@tschk/moonshine-next/navigation"],
|
|
60
|
+
"next/image": ["@tschk/moonshine-next/image"],
|
|
61
|
+
"next/script": ["@tschk/moonshine-next/script"],
|
|
62
|
+
"next/headers": ["@tschk/moonshine-next/headers"],
|
|
63
|
+
"next/dynamic": ["@tschk/moonshine-next/dynamic"],
|
|
64
|
+
"next/font/google": ["@tschk/moonshine-next/font/google"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`tsconfigPaths()` from `@tschk/moonshine-next/aliases` returns exactly that
|
|
71
|
+
object, and `nextAliases` is the raw specifier map if you need to feed another
|
|
72
|
+
bundler (esbuild `alias`, Vite `resolve.alias`, webpack `resolve.alias`).
|
|
73
|
+
|
|
74
|
+
## Supported
|
|
75
|
+
|
|
76
|
+
| Subpath | Provides |
|
|
77
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
78
|
+
| `.` / `./client` | moonshine signals + resources (`"use client"`) |
|
|
79
|
+
| `./navigation` | `useRouter` `usePathname` `useSearchParams` `useParams` `redirect` `permanentRedirect` `notFound` `useSelectedLayoutSegment(s)` + `usePathnameSignal` |
|
|
80
|
+
| `./link` | default `Link` — client navigation, hover/viewport prefetch |
|
|
81
|
+
| `./image` | default `Image` — `src` `alt` `width` `height` `fill` `priority` `loading` `sizes` |
|
|
82
|
+
| `./script` | default `Script` — `strategy` mapped to `async`/`defer` |
|
|
83
|
+
| `./headers` | `headers()` `cookies()` `draftMode()` (server only) |
|
|
84
|
+
| `./dynamic` | default `dynamic()` with `loading`, `ssr: false`, `.preload()` |
|
|
85
|
+
| `./font/google` | named family loaders returning `{ className, style, variable, href }` |
|
|
86
|
+
| `./aliases` | `next/*` → moonshine specifier mapping + Bun plugin |
|
|
87
|
+
| `./shaders` | moonshine WebGL fragment helpers |
|
|
88
|
+
| `./server` | `moonshineRoute` / `moonshineJson` / `moonshineHtml` + page helpers |
|
|
89
|
+
|
|
90
|
+
Behavioural notes where moonshine deliberately differs from Next:
|
|
91
|
+
|
|
92
|
+
- **`Link` prefetch is a browser hint.** It appends `<link rel="prefetch">` on
|
|
93
|
+
hover and on viewport entry. There is no RSC payload to warm, so this is not
|
|
94
|
+
Next's prefetch cache — it only primes the HTTP cache.
|
|
95
|
+
- **`Link` scroll follows the destination.** Next always jumps to the top;
|
|
96
|
+
moonshine only scrolls when the pathname or hash actually changes, so
|
|
97
|
+
query-only navigation (filters, pagination, tabs) keeps your scroll position.
|
|
98
|
+
Pass `scroll` explicitly to force either behaviour.
|
|
99
|
+
- **`router.refresh()` and `router.prefetch()` are no-ops.** Moonshine
|
|
100
|
+
re-renders from its signal graph; there is no server round trip to invalidate.
|
|
101
|
+
- **`redirect()` / `notFound()`** throw `RedirectError` / `NotFoundError`,
|
|
102
|
+
exported from `./navigation` so your renderer can catch them.
|
|
103
|
+
- **`useSearchParams()`** reads the live query string. The moonshine router
|
|
104
|
+
stores only the pathname, so the query is recovered from `window.location`.
|
|
105
|
+
|
|
106
|
+
## Not supported
|
|
107
|
+
|
|
108
|
+
Be aware of these before migrating — none of them are stubbed to silently
|
|
109
|
+
"work":
|
|
110
|
+
|
|
111
|
+
- **No image optimizer.** `next/image` renders a plain `<img>`. `src` is served
|
|
112
|
+
exactly as authored; `quality` and `unoptimized` are accepted and ignored.
|
|
113
|
+
There is no resizing, no format negotiation, no `srcSet` generation, no
|
|
114
|
+
`loader` / `remotePatterns` config, no blur placeholder.
|
|
115
|
+
- **No React Server Components.** No `"use server"`, no Server Actions, no
|
|
116
|
+
server-only component tree, no streaming RSC payload.
|
|
117
|
+
- **No ISR / on-demand revalidation.** No `revalidate`, no `revalidatePath`, no
|
|
118
|
+
`revalidateTag`, no `unstable_cache`, no fetch cache semantics.
|
|
119
|
+
- **No middleware.** `middleware.ts` is not read; there is no edge interception
|
|
120
|
+
layer. Use the moonshine server directly.
|
|
121
|
+
- **No `next/font` self-hosting.** `next/font/google` does not download or
|
|
122
|
+
subset fonts at build time. It returns Next's `{ className, style, variable }`
|
|
123
|
+
shape plus an `href`, and you emit the Google Fonts `<link>` tags yourself via
|
|
124
|
+
`fontLinks()`. That means a third-party request at runtime, which is exactly
|
|
125
|
+
what Next's self-hosting avoids. `next/font/local` is not implemented.
|
|
126
|
+
- **No file-system routing conventions.** `layout.tsx`, `loading.tsx`,
|
|
127
|
+
`error.tsx`, `template.tsx`, route groups, parallel and intercepting routes
|
|
128
|
+
are not interpreted. Use the moonshine router.
|
|
129
|
+
- **No `next/headers` mutation.** `cookies()` is read-only — no `set()` or
|
|
130
|
+
`delete()`. `draftMode()` always reports disabled.
|
|
131
|
+
- **Other `next/*` modules are absent**, including `next/server`
|
|
132
|
+
(`NextRequest` / `NextResponse`), `next/og`, `next/cache`, and
|
|
133
|
+
`next/navigation`'s `unstable_*` APIs. Route Handlers take and return plain
|
|
134
|
+
`Request` / `Response`; see `./server`.
|
|
135
|
+
|
|
136
|
+
Does **not** export `MoonshineRouter` from the client entry — import it from
|
|
137
|
+
`@tschk/moonshine/router` if you want the moonshine router directly.
|
|
30
138
|
|
|
31
139
|
See [Moonshine README](../../README.md).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine-next",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Next.js
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Next.js API surface reimplemented on moonshine — drop Next, keep the imports",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.ts",
|
|
@@ -13,16 +13,19 @@
|
|
|
13
13
|
"./navigation": "./src/navigation.ts",
|
|
14
14
|
"./link": "./src/link.ts",
|
|
15
15
|
"./image": "./src/image.ts",
|
|
16
|
+
"./script": "./src/script.ts",
|
|
16
17
|
"./headers": "./src/headers.ts",
|
|
17
18
|
"./dynamic": "./src/dynamic.ts",
|
|
19
|
+
"./font/google": "./src/font/google.ts",
|
|
20
|
+
"./aliases": "./src/aliases.ts",
|
|
18
21
|
"./shaders": "./src/shaders.ts"
|
|
19
22
|
},
|
|
20
23
|
"dependencies": {
|
|
21
|
-
"@tschk/moonshine": "^0.3.
|
|
22
|
-
"@tschk/moonshine-
|
|
24
|
+
"@tschk/moonshine": "^0.3.3",
|
|
25
|
+
"@tschk/moonshine-router": "^0.3.3",
|
|
26
|
+
"@tschk/moonshine-shaders": "^0.3.3"
|
|
23
27
|
},
|
|
24
28
|
"peerDependencies": {
|
|
25
|
-
"next": ">=15",
|
|
26
29
|
"react": ">=18",
|
|
27
30
|
"react-dom": ">=18"
|
|
28
31
|
},
|
|
@@ -38,7 +41,7 @@
|
|
|
38
41
|
"scripts": {
|
|
39
42
|
"format": "bunx prettier --check .",
|
|
40
43
|
"lint": "bunx oxlint .",
|
|
41
|
-
"typecheck": "
|
|
44
|
+
"typecheck": "bunx tsc --noEmit",
|
|
42
45
|
"test": "bun test"
|
|
43
46
|
}
|
|
44
47
|
}
|
package/src/aliases.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop-in mapping from `next/*` specifiers onto this package.
|
|
3
|
+
*
|
|
4
|
+
* An existing Next app keeps writing `import Link from "next/link"` — the
|
|
5
|
+
* bundler rewrites the specifier, so no source import changes.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { moonshineNextPlugin } from "@tschk/moonshine-next/aliases";
|
|
9
|
+
* await Bun.build({ entrypoints: ["./app.tsx"], plugins: [moonshineNextPlugin()] });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { BunPlugin } from "bun";
|
|
14
|
+
|
|
15
|
+
/** Bare specifier each `next/*` subpath is served by. */
|
|
16
|
+
export const nextAliases: Readonly<Record<string, string>> = {
|
|
17
|
+
"next/link": "@tschk/moonshine-next/link",
|
|
18
|
+
"next/navigation": "@tschk/moonshine-next/navigation",
|
|
19
|
+
"next/image": "@tschk/moonshine-next/image",
|
|
20
|
+
"next/script": "@tschk/moonshine-next/script",
|
|
21
|
+
"next/headers": "@tschk/moonshine-next/headers",
|
|
22
|
+
"next/dynamic": "@tschk/moonshine-next/dynamic",
|
|
23
|
+
"next/font/google": "@tschk/moonshine-next/font/google",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** File in this package backing each alias, relative to `src/`. */
|
|
27
|
+
const files: Readonly<Record<string, string>> = {
|
|
28
|
+
"next/link": "link.ts",
|
|
29
|
+
"next/navigation": "navigation.ts",
|
|
30
|
+
"next/image": "image.ts",
|
|
31
|
+
"next/script": "script.ts",
|
|
32
|
+
"next/headers": "headers.ts",
|
|
33
|
+
"next/dynamic": "dynamic.ts",
|
|
34
|
+
"next/font/google": "font/google.ts",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `compilerOptions.paths` block for an app's tsconfig, so the type checker
|
|
39
|
+
* follows the same mapping the bundler applies.
|
|
40
|
+
*/
|
|
41
|
+
export function tsconfigPaths(): Record<string, string[]> {
|
|
42
|
+
return Object.fromEntries(
|
|
43
|
+
Object.entries(nextAliases).map(([from, to]) => [from, [to]]),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Absolute path this package serves `specifier` from, if it is aliased. */
|
|
48
|
+
export function resolveAlias(specifier: string): string | undefined {
|
|
49
|
+
const file = files[specifier];
|
|
50
|
+
return file === undefined ? undefined : join(import.meta.dir, file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const filter =
|
|
54
|
+
/^next\/(link|navigation|image|script|headers|dynamic|font\/google)$/;
|
|
55
|
+
|
|
56
|
+
/** Bun bundler/runtime plugin rewriting `next/*` to this package. */
|
|
57
|
+
export function moonshineNextPlugin(): BunPlugin {
|
|
58
|
+
return {
|
|
59
|
+
name: "moonshine-next-aliases",
|
|
60
|
+
setup(build) {
|
|
61
|
+
build.onResolve({ filter }, (args) => {
|
|
62
|
+
const path = resolveAlias(args.path);
|
|
63
|
+
return path === undefined ? undefined : { path };
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/dynamic.ts
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* next/dynamic
|
|
4
|
+
* `next/dynamic` on React.lazy.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const Chart = dynamic(() => import("./chart"), { ssr: false });
|
|
8
|
+
* ```
|
|
5
9
|
*/
|
|
6
|
-
|
|
10
|
+
import {
|
|
11
|
+
createElement,
|
|
12
|
+
lazy,
|
|
13
|
+
Suspense,
|
|
14
|
+
type ComponentType,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
} from "react";
|
|
17
|
+
|
|
18
|
+
export type DynamicOptions = {
|
|
19
|
+
/** `false` renders the fallback until the component mounts on the client. */
|
|
20
|
+
ssr?: boolean;
|
|
21
|
+
loading?: ComponentType;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Loader<P> = () => Promise<
|
|
25
|
+
{ default: ComponentType<P> } | ComponentType<P>
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
/** Next's `dynamic()` result also carries `preload()`, as does Solid's `lazy`. */
|
|
29
|
+
export type DynamicComponent<P> = ComponentType<P> & {
|
|
30
|
+
preload(): Promise<{ default: ComponentType<P> }>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default function dynamic<P extends object>(
|
|
34
|
+
loader: Loader<P>,
|
|
35
|
+
options: DynamicOptions = {},
|
|
36
|
+
): DynamicComponent<P> {
|
|
37
|
+
// Solid memoizes the import promise so `preload()` and the first render share
|
|
38
|
+
// one in-flight request instead of importing the chunk twice.
|
|
39
|
+
let pending: Promise<{ default: ComponentType<P> }> | undefined;
|
|
40
|
+
const load = (): Promise<{ default: ComponentType<P> }> =>
|
|
41
|
+
(pending ??= loader().then((mod) =>
|
|
42
|
+
"default" in mod ? mod : { default: mod },
|
|
43
|
+
));
|
|
44
|
+
|
|
45
|
+
const Lazy = lazy(load);
|
|
46
|
+
const Loading = options.loading;
|
|
47
|
+
const fallback: ReactNode = Loading ? createElement(Loading) : null;
|
|
48
|
+
|
|
49
|
+
const DynamicComponent = (props: P): ReactNode => {
|
|
50
|
+
if (options.ssr === false && typeof window === "undefined") return fallback;
|
|
51
|
+
return createElement(Suspense, { fallback }, createElement(Lazy, props));
|
|
52
|
+
};
|
|
53
|
+
DynamicComponent.displayName = "DynamicComponent";
|
|
54
|
+
DynamicComponent.preload = load;
|
|
55
|
+
return DynamicComponent;
|
|
56
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `next/font/google` without the build-time download.
|
|
3
|
+
*
|
|
4
|
+
* Each loader returns the same `{ className, style, variable }` shape Next
|
|
5
|
+
* produces, plus the `<link>` hrefs to reach the family. Emit those with
|
|
6
|
+
* `fontLinks()` in the document head.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const inter = Inter({ subsets: ["latin"] });
|
|
10
|
+
* <body className={inter.className}>
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export type FontOptions = {
|
|
14
|
+
subsets?: string[];
|
|
15
|
+
weight?: string | string[];
|
|
16
|
+
style?: string | string[];
|
|
17
|
+
display?: "auto" | "block" | "swap" | "fallback" | "optional";
|
|
18
|
+
variable?: string;
|
|
19
|
+
preload?: boolean;
|
|
20
|
+
fallback?: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type FontModule = {
|
|
24
|
+
className: string;
|
|
25
|
+
style: { fontFamily: string; fontWeight?: string; fontStyle?: string };
|
|
26
|
+
variable: string;
|
|
27
|
+
/** Stylesheet URL for this family; render it in the document head. */
|
|
28
|
+
href: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const registry = new Map<string, string>();
|
|
32
|
+
|
|
33
|
+
function familyToCss(family: string, fallback?: string[]): string {
|
|
34
|
+
const quoted = `'${family}'`;
|
|
35
|
+
return [quoted, ...(fallback ?? [])].join(", ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function slug(family: string): string {
|
|
39
|
+
return family.toLowerCase().replaceAll(/[^a-z\d]+/g, "-");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildHref(family: string, options: FontOptions): string {
|
|
43
|
+
const weights = Array.isArray(options.weight)
|
|
44
|
+
? options.weight
|
|
45
|
+
: options.weight
|
|
46
|
+
? [options.weight]
|
|
47
|
+
: [];
|
|
48
|
+
const axis = weights.filter((w) => w !== "variable").join(";");
|
|
49
|
+
const name = family.replaceAll(" ", "+");
|
|
50
|
+
const spec = axis ? `${name}:wght@${axis}` : name;
|
|
51
|
+
return `https://fonts.googleapis.com/css2?family=${spec}&display=${options.display ?? "swap"}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loader(family: string) {
|
|
55
|
+
return (options: FontOptions = {}): FontModule => {
|
|
56
|
+
const href = buildHref(family, options);
|
|
57
|
+
registry.set(family, href);
|
|
58
|
+
const className = `__moonshine_font_${slug(family)}`;
|
|
59
|
+
return {
|
|
60
|
+
className,
|
|
61
|
+
variable: options.variable ?? className,
|
|
62
|
+
href,
|
|
63
|
+
style: {
|
|
64
|
+
fontFamily: familyToCss(family, options.fallback),
|
|
65
|
+
...(typeof options.weight === "string" && options.weight !== "variable"
|
|
66
|
+
? { fontWeight: options.weight }
|
|
67
|
+
: {}),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Stylesheet hrefs for every family loaded so far. */
|
|
74
|
+
export function fontHrefs(): string[] {
|
|
75
|
+
return [...registry.values()];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `<link>` tags for every family loaded so far, for the document head. */
|
|
79
|
+
export function fontLinks(): string {
|
|
80
|
+
return [
|
|
81
|
+
'<link rel="preconnect" href="https://fonts.googleapis.com" />',
|
|
82
|
+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
|
|
83
|
+
...fontHrefs().map((href) => `<link rel="stylesheet" href="${href}" />`),
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** CSS binding each generated class to its family. */
|
|
88
|
+
export function fontStyles(): string {
|
|
89
|
+
return [...registry.keys()]
|
|
90
|
+
.map(
|
|
91
|
+
(family) =>
|
|
92
|
+
`.__moonshine_font_${slug(family)}{font-family:${familyToCss(family)}}`,
|
|
93
|
+
)
|
|
94
|
+
.join("");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const Inter = loader("Inter");
|
|
98
|
+
export const Roboto = loader("Roboto");
|
|
99
|
+
export const Roboto_Mono = loader("Roboto Mono");
|
|
100
|
+
export const Open_Sans = loader("Open Sans");
|
|
101
|
+
export const Lato = loader("Lato");
|
|
102
|
+
export const Montserrat = loader("Montserrat");
|
|
103
|
+
export const Poppins = loader("Poppins");
|
|
104
|
+
export const Source_Code_Pro = loader("Source Code Pro");
|
|
105
|
+
export const JetBrains_Mono = loader("JetBrains Mono");
|
|
106
|
+
export const IBM_Plex_Mono = loader("IBM Plex Mono");
|
|
107
|
+
export const Space_Mono = loader("Space Mono");
|
|
108
|
+
export const Space_Grotesk = loader("Space Grotesk");
|
|
109
|
+
export const Chivo_Mono = loader("Chivo Mono");
|
|
110
|
+
export const Geist = loader("Geist");
|
|
111
|
+
export const Geist_Mono = loader("Geist Mono");
|
|
112
|
+
|
|
113
|
+
/** Any family not exported above. */
|
|
114
|
+
export function googleFont(family: string) {
|
|
115
|
+
return loader(family);
|
|
116
|
+
}
|
package/src/headers.ts
CHANGED
|
@@ -1,8 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* next/headers
|
|
2
|
+
* `next/headers` backed by the moonshine request context.
|
|
3
|
+
*
|
|
4
|
+
* Server only. The request is supplied by the renderer via
|
|
5
|
+
* `runWithRequest`, which the moonshine server calls per request.
|
|
3
6
|
*
|
|
4
7
|
* ```ts
|
|
5
8
|
* import { headers, cookies } from "@tschk/moonshine-next/headers";
|
|
6
9
|
* ```
|
|
7
10
|
*/
|
|
8
|
-
|
|
11
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
|
+
|
|
13
|
+
const store = new AsyncLocalStorage<Request>();
|
|
14
|
+
|
|
15
|
+
/** Runs `fn` with `request` visible to `headers()` and `cookies()`. */
|
|
16
|
+
export function runWithRequest<T>(request: Request, fn: () => T): T {
|
|
17
|
+
return store.run(request, fn);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function current(): Request {
|
|
21
|
+
const request = store.getStore();
|
|
22
|
+
if (!request) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"headers() was called outside a request. Wrap the render in runWithRequest().",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return request;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function headers(): Headers {
|
|
31
|
+
return current().headers;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CookieEntry = { name: string; value: string };
|
|
35
|
+
|
|
36
|
+
function parseCookies(header: string | null): Map<string, string> {
|
|
37
|
+
const jar = new Map<string, string>();
|
|
38
|
+
if (!header) return jar;
|
|
39
|
+
for (const part of header.split(";")) {
|
|
40
|
+
const index = part.indexOf("=");
|
|
41
|
+
if (index === -1) continue;
|
|
42
|
+
const name = part.slice(0, index).trim();
|
|
43
|
+
if (name) jar.set(name, decodeURIComponent(part.slice(index + 1).trim()));
|
|
44
|
+
}
|
|
45
|
+
return jar;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function cookies(): {
|
|
49
|
+
get(name: string): CookieEntry | undefined;
|
|
50
|
+
getAll(): CookieEntry[];
|
|
51
|
+
has(name: string): boolean;
|
|
52
|
+
} {
|
|
53
|
+
const jar = parseCookies(current().headers.get("cookie"));
|
|
54
|
+
return {
|
|
55
|
+
get: (name) => {
|
|
56
|
+
const value = jar.get(name);
|
|
57
|
+
return value === undefined ? undefined : { name, value };
|
|
58
|
+
},
|
|
59
|
+
getAll: () => [...jar].map(([name, value]) => ({ name, value })),
|
|
60
|
+
has: (name) => jar.has(name),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function draftMode(): { isEnabled: boolean } {
|
|
65
|
+
return { isEnabled: false };
|
|
66
|
+
}
|
package/src/image.ts
CHANGED
|
@@ -1,11 +1,67 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* next/image
|
|
2
|
+
* `next/image` as a plain `<img>`.
|
|
3
|
+
*
|
|
4
|
+
* There is no image optimizer behind moonshine, so `src` is served as authored
|
|
5
|
+
* and the layout props map onto native attributes.
|
|
5
6
|
*
|
|
6
7
|
* ```ts
|
|
7
8
|
* import Image from "@tschk/moonshine-next/image";
|
|
8
9
|
* ```
|
|
9
10
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
import {
|
|
12
|
+
createElement,
|
|
13
|
+
type CSSProperties,
|
|
14
|
+
type ImgHTMLAttributes,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
} from "react";
|
|
17
|
+
|
|
18
|
+
export type ImageProps = Omit<
|
|
19
|
+
ImgHTMLAttributes<HTMLImageElement>,
|
|
20
|
+
"src" | "width" | "height" | "loading"
|
|
21
|
+
> & {
|
|
22
|
+
src: string | { src: string; width?: number; height?: number };
|
|
23
|
+
alt: string;
|
|
24
|
+
width?: number | string;
|
|
25
|
+
height?: number | string;
|
|
26
|
+
fill?: boolean;
|
|
27
|
+
priority?: boolean;
|
|
28
|
+
quality?: number;
|
|
29
|
+
loading?: "eager" | "lazy";
|
|
30
|
+
unoptimized?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const FILL_STYLE: CSSProperties = {
|
|
34
|
+
position: "absolute",
|
|
35
|
+
inset: 0,
|
|
36
|
+
width: "100%",
|
|
37
|
+
height: "100%",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default function Image({
|
|
41
|
+
src,
|
|
42
|
+
alt,
|
|
43
|
+
width,
|
|
44
|
+
height,
|
|
45
|
+
fill,
|
|
46
|
+
priority,
|
|
47
|
+
quality,
|
|
48
|
+
loading,
|
|
49
|
+
unoptimized,
|
|
50
|
+
style,
|
|
51
|
+
...rest
|
|
52
|
+
}: ImageProps): ReactNode {
|
|
53
|
+
void quality;
|
|
54
|
+
void unoptimized;
|
|
55
|
+
const resolved = typeof src === "string" ? { src } : src;
|
|
56
|
+
return createElement("img", {
|
|
57
|
+
...rest,
|
|
58
|
+
src: resolved.src,
|
|
59
|
+
alt,
|
|
60
|
+
width: fill ? undefined : (width ?? resolved.width),
|
|
61
|
+
height: fill ? undefined : (height ?? resolved.height),
|
|
62
|
+
loading: loading ?? (priority ? "eager" : "lazy"),
|
|
63
|
+
decoding: priority ? "sync" : "async",
|
|
64
|
+
fetchPriority: priority ? "high" : undefined,
|
|
65
|
+
style: fill ? { ...FILL_STYLE, ...style } : style,
|
|
66
|
+
});
|
|
67
|
+
}
|
package/src/link.ts
CHANGED
|
@@ -1,11 +1,162 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* next/link
|
|
4
|
+
* `next/link` implemented on the moonshine router.
|
|
5
5
|
*
|
|
6
6
|
* ```ts
|
|
7
7
|
* import Link from "@tschk/moonshine-next/link";
|
|
8
8
|
* ```
|
|
9
9
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
|
|
22
|
+
export type LinkProps = Omit<
|
|
23
|
+
AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
24
|
+
"href"
|
|
25
|
+
> & {
|
|
26
|
+
href: string | { pathname?: string; query?: Record<string, string> };
|
|
27
|
+
replace?: boolean;
|
|
28
|
+
scroll?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Emits a `<link rel="prefetch">` browser hint. Moonshine has no RSC payload
|
|
31
|
+
* to warm, so this is not Next's prefetch cache.
|
|
32
|
+
*/
|
|
33
|
+
prefetch?: boolean | null;
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function hrefToString(href: LinkProps["href"]): string {
|
|
38
|
+
if (typeof href === "string") return href;
|
|
39
|
+
const pathname = href.pathname ?? "";
|
|
40
|
+
const entries = Object.entries(href.query ?? {});
|
|
41
|
+
if (entries.length === 0) return pathname;
|
|
42
|
+
return `${pathname}?${new URLSearchParams(entries).toString()}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** External and non-navigational targets must keep the browser's own handling. */
|
|
46
|
+
function isExternal(href: string, target?: string): boolean {
|
|
47
|
+
if (target && target !== "_self") return true;
|
|
48
|
+
return (
|
|
49
|
+
/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) ||
|
|
50
|
+
href.startsWith("//") ||
|
|
51
|
+
href.startsWith("#")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const prefetched = new Set<string>();
|
|
56
|
+
|
|
57
|
+
/** Warms the browser cache for `url` once, the way Waku prefetches a route. */
|
|
58
|
+
function prefetchOnce(url: string): void {
|
|
59
|
+
if (prefetched.has(url) || typeof document === "undefined") return;
|
|
60
|
+
if (isCurrentUrl(url)) return;
|
|
61
|
+
prefetched.add(url);
|
|
62
|
+
const tag = document.createElement("link");
|
|
63
|
+
tag.rel = "prefetch";
|
|
64
|
+
tag.href = url;
|
|
65
|
+
document.head.append(tag);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when `url` resolves to the page already showing. */
|
|
69
|
+
function isCurrentUrl(url: string): boolean {
|
|
70
|
+
if (typeof window === "undefined") return false;
|
|
71
|
+
return new URL(url, window.location.href).href === window.location.href;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Next always jumps to the top; Waku only scrolls when the destination is a
|
|
76
|
+
* different document position. Following Waku keeps query-only navigations
|
|
77
|
+
* (filters, tabs) from throwing the reader back to the top of the page.
|
|
78
|
+
*/
|
|
79
|
+
function shouldScrollByDefault(url: string): boolean {
|
|
80
|
+
if (typeof window === "undefined") return false;
|
|
81
|
+
const next = new URL(url, window.location.href);
|
|
82
|
+
return (
|
|
83
|
+
next.pathname !== window.location.pathname ||
|
|
84
|
+
next.hash !== window.location.hash
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function Link({
|
|
89
|
+
href,
|
|
90
|
+
replace,
|
|
91
|
+
scroll,
|
|
92
|
+
prefetch = true,
|
|
93
|
+
children,
|
|
94
|
+
onClick,
|
|
95
|
+
onMouseEnter,
|
|
96
|
+
...rest
|
|
97
|
+
}: LinkProps): ReactNode {
|
|
98
|
+
const url = hrefToString(href);
|
|
99
|
+
const external = isExternal(url, rest.target);
|
|
100
|
+
const ref = useRef<HTMLAnchorElement>(null);
|
|
101
|
+
// Navigation runs in a transition so a slow subtree cannot block the click.
|
|
102
|
+
const [, startTransition] = useTransition();
|
|
103
|
+
|
|
104
|
+
const warm = useCallback(() => {
|
|
105
|
+
if (prefetch !== false && !external) prefetchOnce(url);
|
|
106
|
+
}, [prefetch, external, url]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const node = ref.current;
|
|
110
|
+
if (!node || prefetch === false || external) return;
|
|
111
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
112
|
+
// Waku's threshold: a one-pixel sliver of a link is not intent to visit it.
|
|
113
|
+
const observer = new IntersectionObserver(
|
|
114
|
+
(entries) => {
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (entry.isIntersecting) {
|
|
117
|
+
warm();
|
|
118
|
+
observer.disconnect();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{ threshold: 0.1 },
|
|
123
|
+
);
|
|
124
|
+
observer.observe(node);
|
|
125
|
+
return () => observer.disconnect();
|
|
126
|
+
}, [warm, prefetch, external]);
|
|
127
|
+
|
|
128
|
+
return createElement(
|
|
129
|
+
"a",
|
|
130
|
+
{
|
|
131
|
+
...rest,
|
|
132
|
+
ref,
|
|
133
|
+
href: url,
|
|
134
|
+
onMouseEnter: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
135
|
+
onMouseEnter?.(event);
|
|
136
|
+
warm();
|
|
137
|
+
},
|
|
138
|
+
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
139
|
+
onClick?.(event);
|
|
140
|
+
if (
|
|
141
|
+
event.defaultPrevented ||
|
|
142
|
+
event.button !== 0 ||
|
|
143
|
+
event.metaKey ||
|
|
144
|
+
event.altKey ||
|
|
145
|
+
event.ctrlKey ||
|
|
146
|
+
event.shiftKey ||
|
|
147
|
+
external
|
|
148
|
+
) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
if (isCurrentUrl(url)) return;
|
|
153
|
+
const jump = scroll ?? shouldScrollByDefault(url);
|
|
154
|
+
startTransition(() => {
|
|
155
|
+
navigate(url, { replace });
|
|
156
|
+
if (jump) globalThis.scrollTo?.(0, 0);
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
children,
|
|
161
|
+
);
|
|
162
|
+
}
|
package/src/navigation.ts
CHANGED
|
@@ -1,76 +1,139 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* next/navigation
|
|
4
|
+
* `next/navigation` implemented on the moonshine router.
|
|
5
5
|
*
|
|
6
6
|
* ```ts
|
|
7
|
-
* import {
|
|
7
|
+
* import { useRouter, usePathname } from "@tschk/moonshine-next/navigation";
|
|
8
8
|
* ```
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { useMemo } from "react";
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
notFound,
|
|
17
|
-
permanentRedirect,
|
|
18
|
-
redirect,
|
|
19
|
-
useSelectedLayoutSegment,
|
|
20
|
-
useSelectedLayoutSegments,
|
|
21
|
-
} from "next/navigation";
|
|
12
|
+
navigate,
|
|
13
|
+
useLocation,
|
|
14
|
+
useParams as useMoonshineParams,
|
|
15
|
+
} from "@tschk/moonshine/router";
|
|
22
16
|
import { createSignal, useSignal, type Signal } from "@tschk/moonshine/react";
|
|
23
17
|
|
|
24
|
-
export {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
notFound,
|
|
32
|
-
useSelectedLayoutSegment,
|
|
33
|
-
useSelectedLayoutSegments,
|
|
18
|
+
export type AppRouterInstance = {
|
|
19
|
+
push(href: string, options?: { scroll?: boolean }): void;
|
|
20
|
+
replace(href: string, options?: { scroll?: boolean }): void;
|
|
21
|
+
back(): void;
|
|
22
|
+
forward(): void;
|
|
23
|
+
refresh(): void;
|
|
24
|
+
prefetch(href: string): void;
|
|
34
25
|
};
|
|
35
26
|
|
|
36
|
-
/**
|
|
37
|
-
export function
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
ref.current.set(pathname);
|
|
44
|
-
}, [pathname]);
|
|
45
|
-
return signal;
|
|
27
|
+
/** Splits `"/a/b?x=1"` into its pathname and its raw query string. */
|
|
28
|
+
export function splitLocation(location: string): [string, string] {
|
|
29
|
+
const index = location.indexOf("?");
|
|
30
|
+
return index === -1
|
|
31
|
+
? [location, ""]
|
|
32
|
+
: [location.slice(0, index), location.slice(index + 1)];
|
|
46
33
|
}
|
|
47
34
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return
|
|
35
|
+
/**
|
|
36
|
+
* The moonshine router stores only the pathname, so a query string survives
|
|
37
|
+
* navigation on `window.location` but not in the location signal. Prefer the
|
|
38
|
+
* signal when it carries one (SSR / controlled paths) and fall back to the
|
|
39
|
+
* browser's own search.
|
|
40
|
+
*/
|
|
41
|
+
function readSearch(location: string): string {
|
|
42
|
+
const fromSignal = splitLocation(location)[1];
|
|
43
|
+
if (fromSignal) return fromSignal;
|
|
44
|
+
if (typeof window === "undefined") return "";
|
|
45
|
+
return window.location.search.replace(/^\?/, "");
|
|
57
46
|
}
|
|
58
47
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
48
|
+
function shouldScrollByDefault(href: string): boolean {
|
|
49
|
+
if (typeof window === "undefined") return false;
|
|
50
|
+
const next = new URL(href, window.location.href);
|
|
51
|
+
return (
|
|
52
|
+
next.pathname !== window.location.pathname ||
|
|
53
|
+
next.hash !== window.location.hash
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useRouter(): AppRouterInstance {
|
|
58
|
+
return useMemo(
|
|
59
|
+
() => ({
|
|
60
|
+
push: (href, options) => {
|
|
61
|
+
const jump = options?.scroll ?? shouldScrollByDefault(href);
|
|
62
|
+
navigate(href);
|
|
63
|
+
if (jump) globalThis.scrollTo?.(0, 0);
|
|
64
|
+
},
|
|
65
|
+
replace: (href, options) => {
|
|
66
|
+
const jump = options?.scroll ?? shouldScrollByDefault(href);
|
|
67
|
+
navigate(href, { replace: true });
|
|
68
|
+
if (jump) globalThis.scrollTo?.(0, 0);
|
|
69
|
+
},
|
|
70
|
+
back: () => globalThis.history?.back(),
|
|
71
|
+
forward: () => globalThis.history?.forward(),
|
|
72
|
+
// Moonshine re-renders from its signal graph, so there is no server
|
|
73
|
+
// round trip to invalidate.
|
|
74
|
+
refresh: () => {},
|
|
75
|
+
prefetch: () => {},
|
|
76
|
+
}),
|
|
77
|
+
[],
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function usePathname(): string {
|
|
82
|
+
return splitLocation(useLocation())[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useSearchParams(): URLSearchParams {
|
|
86
|
+
const query = readSearch(useLocation());
|
|
87
|
+
return useMemo(() => new URLSearchParams(query), [query]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function useParams(): Record<string, string> {
|
|
91
|
+
return useMoonshineParams();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function useSelectedLayoutSegments(): string[] {
|
|
95
|
+
return usePathname().split("/").filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function useSelectedLayoutSegment(): string | null {
|
|
99
|
+
return useSelectedLayoutSegments()[0] ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Thrown to unwind rendering, matching Next's control-flow contract. */
|
|
103
|
+
export class RedirectError extends Error {
|
|
104
|
+
constructor(
|
|
105
|
+
readonly url: string,
|
|
106
|
+
readonly permanent: boolean,
|
|
107
|
+
) {
|
|
108
|
+
super(`redirect: ${url}`);
|
|
109
|
+
this.name = "RedirectError";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class NotFoundError extends Error {
|
|
114
|
+
constructor() {
|
|
115
|
+
super("not found");
|
|
116
|
+
this.name = "NotFoundError";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function redirect(url: string): never {
|
|
121
|
+
throw new RedirectError(url, false);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function permanentRedirect(url: string): never {
|
|
125
|
+
throw new RedirectError(url, true);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function notFound(): never {
|
|
129
|
+
throw new NotFoundError();
|
|
71
130
|
}
|
|
72
131
|
|
|
73
|
-
/**
|
|
74
|
-
export function
|
|
75
|
-
|
|
132
|
+
/** Pathname as a live moonshine signal. */
|
|
133
|
+
export function usePathnameSignal(): Signal<string> {
|
|
134
|
+
const pathname = usePathname();
|
|
135
|
+
const signal = useMemo(() => createSignal(pathname), []);
|
|
136
|
+
if (signal() !== pathname) signal.set(pathname);
|
|
137
|
+
useSignal(signal);
|
|
138
|
+
return signal;
|
|
76
139
|
}
|
package/src/script.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `next/script` as a plain `<script>`.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import Script from "@tschk/moonshine-next/script";
|
|
6
|
+
* ```
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createElement,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
type ScriptHTMLAttributes,
|
|
12
|
+
} from "react";
|
|
13
|
+
|
|
14
|
+
export type ScriptProps = ScriptHTMLAttributes<HTMLScriptElement> & {
|
|
15
|
+
/** Next's loading hints map onto `async` / `defer`. */
|
|
16
|
+
strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload" | "worker";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default function Script({
|
|
20
|
+
strategy = "afterInteractive",
|
|
21
|
+
children,
|
|
22
|
+
...rest
|
|
23
|
+
}: ScriptProps): ReactNode {
|
|
24
|
+
return createElement(
|
|
25
|
+
"script",
|
|
26
|
+
{
|
|
27
|
+
...rest,
|
|
28
|
+
async: rest.async ?? strategy === "lazyOnload",
|
|
29
|
+
defer: rest.defer ?? strategy === "afterInteractive",
|
|
30
|
+
},
|
|
31
|
+
children,
|
|
32
|
+
);
|
|
33
|
+
}
|
package/src/shims.d.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/** Ambient types so adapter builds without installing Next in every workspace. */
|
|
2
|
-
declare module "next/navigation" {
|
|
3
|
-
export function useRouter(): {
|
|
4
|
-
push(href: string): void;
|
|
5
|
-
replace(href: string): void;
|
|
6
|
-
back(): void;
|
|
7
|
-
forward(): void;
|
|
8
|
-
refresh(): void;
|
|
9
|
-
prefetch(href: string): void;
|
|
10
|
-
};
|
|
11
|
-
export function usePathname(): string;
|
|
12
|
-
export function useSearchParams(): URLSearchParams;
|
|
13
|
-
export function useParams<
|
|
14
|
-
T extends Record<string, string | string[]> = Record<string, string>,
|
|
15
|
-
>(): T;
|
|
16
|
-
export function redirect(url: string): never;
|
|
17
|
-
export function notFound(): never;
|
|
18
|
-
export function permanentRedirect(url: string): never;
|
|
19
|
-
export function useSelectedLayoutSegment(): string | null;
|
|
20
|
-
export function useSelectedLayoutSegments(): string[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
declare module "next/link" {
|
|
24
|
-
import type {
|
|
25
|
-
ComponentType,
|
|
26
|
-
ReactNode,
|
|
27
|
-
CSSProperties,
|
|
28
|
-
MouseEventHandler,
|
|
29
|
-
} from "react";
|
|
30
|
-
export type LinkProps = {
|
|
31
|
-
href: string;
|
|
32
|
-
children?: ReactNode;
|
|
33
|
-
className?: string;
|
|
34
|
-
style?: CSSProperties;
|
|
35
|
-
replace?: boolean;
|
|
36
|
-
scroll?: boolean;
|
|
37
|
-
prefetch?: boolean;
|
|
38
|
-
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
39
|
-
[key: string]: unknown;
|
|
40
|
-
};
|
|
41
|
-
const Link: ComponentType<LinkProps>;
|
|
42
|
-
export default Link;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
declare module "next/image" {
|
|
46
|
-
import type { ComponentType, CSSProperties } from "react";
|
|
47
|
-
export type ImageProps = {
|
|
48
|
-
src: string | { src: string; height: number; width: number };
|
|
49
|
-
alt: string;
|
|
50
|
-
width?: number;
|
|
51
|
-
height?: number;
|
|
52
|
-
fill?: boolean;
|
|
53
|
-
className?: string;
|
|
54
|
-
style?: CSSProperties;
|
|
55
|
-
priority?: boolean;
|
|
56
|
-
quality?: number;
|
|
57
|
-
sizes?: string;
|
|
58
|
-
[key: string]: unknown;
|
|
59
|
-
};
|
|
60
|
-
const Image: ComponentType<ImageProps>;
|
|
61
|
-
export default Image;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
declare module "next/headers" {
|
|
65
|
-
export function headers(): Headers;
|
|
66
|
-
export function cookies(): {
|
|
67
|
-
get(name: string): { name: string; value: string } | undefined;
|
|
68
|
-
getAll(): Array<{ name: string; value: string }>;
|
|
69
|
-
has(name: string): boolean;
|
|
70
|
-
set(name: string, value: string): void;
|
|
71
|
-
delete(name: string): void;
|
|
72
|
-
};
|
|
73
|
-
export function draftMode(): {
|
|
74
|
-
isEnabled: boolean;
|
|
75
|
-
enable(): void;
|
|
76
|
-
disable(): void;
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
declare module "next/dynamic" {
|
|
81
|
-
import type { ComponentType } from "react";
|
|
82
|
-
export default function dynamic<P = object>(
|
|
83
|
-
loader: () => Promise<{ default: ComponentType<P> } | ComponentType<P>>,
|
|
84
|
-
options?: { ssr?: boolean; loading?: ComponentType },
|
|
85
|
-
): ComponentType<P>;
|
|
86
|
-
}
|