@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 CHANGED
@@ -1,31 +1,139 @@
1
1
  # `@tschk/moonshine-next`
2
2
 
3
- Next.js adapter for Moonshine. Re-exports real Next.js libraries on subpaths
4
- plus moonshine signal bridges.
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, usePathnameSignal } from "@tschk/moonshine-next/navigation";
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
- | Subpath | Host library |
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
- Peer: `next` >= 15, `react` >= 18, `react-dom` >= 18.
19
+ ## Drop-in: keep your existing imports
28
20
 
29
- Does **not** export `MoonshineRouter` — use the Next.js App Router.
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.1",
4
- "description": "Next.js adapter — moonshine signals + next/navigation|link|image|headers|shaders",
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.1",
22
- "@tschk/moonshine-shaders": "^0.3.1"
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": "exit 0",
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 re-export for lazy client islands.
4
+ * `next/dynamic` on React.lazy.
5
+ *
6
+ * ```ts
7
+ * const Chart = dynamic(() => import("./chart"), { ssr: false });
8
+ * ```
5
9
  */
6
- export { default } from "next/dynamic";
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 re-export (Server Components / Route Handlers only).
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
- export { cookies, draftMode, headers } from "next/headers";
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 re-export for moonshine apps.
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
- export { default } from "next/image";
11
- export type { ImageProps } from "next/image";
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 re-export for moonshine apps.
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
- export { default } from "next/link";
11
- export type { LinkProps } from "next/link";
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 + moonshine signal bridges.
4
+ * `next/navigation` implemented on the moonshine router.
5
5
  *
6
6
  * ```ts
7
- * import { usePathname, usePathnameSignal, useRouter } from "@tschk/moonshine-next/navigation";
7
+ * import { useRouter, usePathname } from "@tschk/moonshine-next/navigation";
8
8
  * ```
9
9
  */
10
- import { useEffect, useMemo, useRef } from "react";
10
+ import { useMemo } from "react";
11
11
  import {
12
- useParams as useNextParams,
13
- usePathname as useNextPathname,
14
- useRouter as useNextRouter,
15
- useSearchParams as useNextSearchParams,
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
- useNextRouter as useRouter,
26
- useNextPathname as usePathname,
27
- useNextSearchParams as useSearchParams,
28
- useNextParams as useParams,
29
- redirect,
30
- permanentRedirect,
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
- /** Pathname as a live moonshine signal (updates on App Router navigation). */
37
- export function usePathnameSignal(): Signal<string> {
38
- const pathname = useNextPathname();
39
- const signal = useMemo(() => createSignal(pathname), []);
40
- const ref = useRef(signal);
41
- ref.current = signal;
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
- /** Search string `?a=1` as signal. */
49
- export function useSearchSignal(): Signal<string> {
50
- const sp = useNextSearchParams();
51
- const value = sp?.toString() ? `?${sp.toString()}` : "";
52
- const signal = useMemo(() => createSignal(value), []);
53
- useEffect(() => {
54
- signal.set(value);
55
- }, [value, signal]);
56
- return signal;
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
- /** Read a single search param as signal. */
60
- export function useSearchParamSignal(
61
- name: string,
62
- fallback = "",
63
- ): Signal<string> {
64
- const sp = useNextSearchParams();
65
- const value = sp?.get(name) ?? fallback;
66
- const signal = useMemo(() => createSignal(value), []);
67
- useEffect(() => {
68
- signal.set(value);
69
- }, [value, signal]);
70
- return signal;
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
- /** Convenience: useSignal(usePathnameSignal()). */
74
- export function useReactivePathname(): string {
75
- return useSignal(usePathnameSignal());
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
- }