@paramour-js/next 0.3.0 → 0.4.0

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 ADDED
@@ -0,0 +1,48 @@
1
+ # @paramour-js/next
2
+
3
+ The Next.js integration for [paramour](https://paramour.dev):
4
+ `withTypedRoutes` (build-time registry generation and drift checking),
5
+ typed client hooks for both routers, and the `paramour` CLI
6
+ (`generate` / `check` / `init` / `list` / `doctor`).
7
+
8
+ ```sh
9
+ pnpm add paramour @paramour-js/next
10
+ ```
11
+
12
+ Wrap your Next config so the route registry regenerates on dev and is
13
+ enforced on build:
14
+
15
+ ```ts
16
+ // next.config.ts
17
+ import { withTypedRoutes } from "@paramour-js/next";
18
+
19
+ export default withTypedRoutes({}, { strict: true });
20
+ ```
21
+
22
+ Client components read the URL through hooks that take the same route
23
+ object as everything else:
24
+
25
+ ```tsx
26
+ "use client";
27
+
28
+ import { useSearch } from "@paramour-js/next/app";
29
+
30
+ import { productRoute } from "./route.def";
31
+
32
+ export function FilterSummary() {
33
+ const search = useSearch(productRoute);
34
+ if (search.status === "error") return <p role="alert">Bad filters</p>;
35
+ return <p>query: {search.data.q ?? "none"}</p>;
36
+ }
37
+ ```
38
+
39
+ ## Docs
40
+
41
+ - [Getting started](https://paramour.dev/docs/getting-started)
42
+ - [Next API reference](https://paramour.dev/docs/reference/next)
43
+ - [CLI reference](https://paramour.dev/docs/reference/next/cli)
44
+ - [Hooks guide](https://paramour.dev/docs/guides/hooks)
45
+
46
+ ## License
47
+
48
+ MIT © Jason Paff
package/dist/app.js CHANGED
@@ -1,13 +1,16 @@
1
1
  "use client";
2
2
  import { useParams, usePathname, useRouter, useSearchParams, } from "next/navigation";
3
3
  import { decodeParams, decodeSearch, ParamsDecodeError, safeDecodeParams, safeDecodeSearch, SearchDecodeError, } from "paramour";
4
+ import { useContext } from "react";
4
5
  import { searchWireSnapshot } from "./devtools-seam.js";
6
+ import { AppNavigationContext, } from "./navigation-adapter.js";
5
7
  import { makeAppNavigate, useDevtoolsEmitter, } from "./observe.js";
6
8
  import { paramsFingerprint, searchParamsFingerprint, useSelectedResult, useSelectedValue, useStableResult, } from "./select.js";
7
9
  export function useRouteParams(route, options) {
8
- const params = useParams() ?? {};
9
- const router = useRouter();
10
- const pathname = usePathname();
10
+ const nav = useAppNavigation();
11
+ const params = nav.useParams() ?? {};
12
+ const router = nav.useRouter();
13
+ const pathname = nav.usePathname();
11
14
  const emitter = useDevtoolsEmitter();
12
15
  const spec = process.env.NODE_ENV === "production"
13
16
  ? undefined
@@ -33,9 +36,10 @@ export function useRouteParams(route, options) {
33
36
  return useSelectedResult(result, options);
34
37
  }
35
38
  export function useRouteParamsOrThrow(route, options) {
36
- const params = useParams() ?? {};
37
- const router = useRouter();
38
- const pathname = usePathname();
39
+ const nav = useAppNavigation();
40
+ const params = nav.useParams() ?? {};
41
+ const router = nav.useRouter();
42
+ const pathname = nav.usePathname();
39
43
  const emitter = useDevtoolsEmitter();
40
44
  const spec = process.env.NODE_ENV === "production"
41
45
  ? undefined
@@ -77,9 +81,10 @@ export function useRouteParamsOrThrow(route, options) {
77
81
  return useSelectedValue(value, options);
78
82
  }
79
83
  export function useSearch(route, options) {
80
- const searchParams = useSearchParams();
81
- const router = useRouter();
82
- const pathname = usePathname();
84
+ const nav = useAppNavigation();
85
+ const searchParams = nav.useSearchParams();
86
+ const router = nav.useRouter();
87
+ const pathname = nav.usePathname();
83
88
  const emitter = useDevtoolsEmitter();
84
89
  const spec = process.env.NODE_ENV === "production"
85
90
  ? undefined
@@ -105,9 +110,10 @@ export function useSearch(route, options) {
105
110
  return useSelectedResult(result, options);
106
111
  }
107
112
  export function useSearchOrThrow(route, options) {
108
- const searchParams = useSearchParams();
109
- const router = useRouter();
110
- const pathname = usePathname();
113
+ const nav = useAppNavigation();
114
+ const searchParams = nav.useSearchParams();
115
+ const router = nav.useRouter();
116
+ const pathname = nav.usePathname();
111
117
  const emitter = useDevtoolsEmitter();
112
118
  const spec = process.env.NODE_ENV === "production"
113
119
  ? undefined
@@ -156,3 +162,25 @@ export function useSearchOrThrow(route, options) {
156
162
  }
157
163
  return useSelectedValue(value, options);
158
164
  }
165
+ /**
166
+ * Real-Next fallback for the adapter seam (design-16 TA3): the /testing
167
+ * provider overrides these reads through {@link AppNavigationContext}; with
168
+ * no provider mounted the context's `null` default resolves here, so
169
+ * production behavior (and this module's `next/navigation`-only bundle
170
+ * graph, per dist.test.ts) is unchanged.
171
+ */
172
+ const realAppAdapter = {
173
+ useParams,
174
+ usePathname,
175
+ useRouter,
176
+ useSearchParams,
177
+ };
178
+ /**
179
+ * Every hook resolves the adapter ONCE at its top and calls the adapter's
180
+ * reads unconditionally, exactly where the direct Next calls previously sat
181
+ * — hook call order is identical across renders and across provider
182
+ * presence (TA4).
183
+ */
184
+ function useAppNavigation() {
185
+ return useContext(AppNavigationContext) ?? realAppAdapter;
186
+ }
@@ -0,0 +1,60 @@
1
+ import type { ParamsSource } from "paramour";
2
+ /**
3
+ * Adapter seam for the client hooks' framework reads (design-16 TA1): each
4
+ * flavor's hooks resolve Next through a React context so the /testing entry
5
+ * can override the reads without runner-specific module mocking.
6
+ * Deliberately NO `"use client"` directive — app.ts (which carries one) and
7
+ * pages.ts (which must not, PR2) both import from here, like
8
+ * observe.ts/select.ts; the directive belongs on the entry modules, not a
9
+ * shared leaf.
10
+ *
11
+ * This module is Next-free on purpose (TA3): the contexts default to `null`
12
+ * and each flavor ENTRY supplies its own real-Next fallback
13
+ * (`useContext(ctx) ?? realAdapter`), so neither this module nor the
14
+ * /testing entry ever drags a `next/*` specifier into its graph. The
15
+ * dist.test.ts bundle-hygiene invariants (/app reaches only
16
+ * `next/navigation`, /pages only `next/router.js`, /testing neither) depend
17
+ * on exactly this split — a context whose DEFAULT VALUE were the real
18
+ * adapter would break all three.
19
+ */
20
+ /**
21
+ * App-flavor adapter: EXACTLY the ambient view of `next/navigation` declared
22
+ * in `src/types/next-navigation.d.ts` — that ambient is the contract of
23
+ * record, and this interface must stay in lockstep with it (TA2; the
24
+ * `examples/next-compat` pins guard the real-Next side). `useParams()`'s
25
+ * `null` arm is the outside-App-Router-tree state (Next #48058 family) the
26
+ * hooks deliberately tolerate; `useRouter().replace`/`usePathname` are the
27
+ * devtools `navigate` capability's write path and resolution base (DT8).
28
+ */
29
+ export interface AppNavigationAdapter {
30
+ useParams(): null | ParamsSource;
31
+ usePathname(): string;
32
+ useRouter(): {
33
+ replace(href: string): void;
34
+ };
35
+ useSearchParams(): URLSearchParams;
36
+ }
37
+ /**
38
+ * Pages-flavor adapter: EXACTLY the ambient view of `next/router.js`
39
+ * declared in `src/types/next-router.d.ts` — that ambient is the contract of
40
+ * record, and this interface must stay in lockstep with it (TA2). The
41
+ * ambient's throw-on-unmounted behavior under `app/` (PR5) is part of the
42
+ * contract: adapter implementations reproduce it by THROWING from
43
+ * `useRouter()`, which pages.ts translates.
44
+ */
45
+ export interface PagesNavigationAdapter {
46
+ useRouter(): {
47
+ asPath: string;
48
+ isReady: boolean;
49
+ query: ParamsSource;
50
+ replace(url: string): Promise<boolean>;
51
+ };
52
+ }
53
+ /**
54
+ * The `null` default is load-bearing (TA3): with no provider mounted,
55
+ * app.ts falls back to its real `next/navigation` adapter — zero markup and
56
+ * zero behavior change in production (TA4).
57
+ */
58
+ export declare const AppNavigationContext: import("react").Context<AppNavigationAdapter | null>;
59
+ /** The pages twin; `null` default load-bearing for the same TA3 reasons. */
60
+ export declare const PagesNavigationContext: import("react").Context<PagesNavigationAdapter | null>;
@@ -0,0 +1,9 @@
1
+ import { createContext } from "react";
2
+ /**
3
+ * The `null` default is load-bearing (TA3): with no provider mounted,
4
+ * app.ts falls back to its real `next/navigation` adapter — zero markup and
5
+ * zero behavior change in production (TA4).
6
+ */
7
+ export const AppNavigationContext = createContext(null);
8
+ /** The pages twin; `null` default load-bearing for the same TA3 reasons. */
9
+ export const PagesNavigationContext = createContext(null);
package/dist/pages.js CHANGED
@@ -6,7 +6,9 @@
6
6
  // every bundler. Guarded by the examples/next-compat build-app.
7
7
  import { useRouter } from "next/router.js";
8
8
  import { ParamourError, safeDecodeParams, safeDecodeSearch, } from "paramour";
9
+ import { useContext } from "react";
9
10
  import { recordWireSnapshot } from "./devtools-seam.js";
11
+ import { PagesNavigationContext, } from "./navigation-adapter.js";
10
12
  import { makePagesNavigate, useDevtoolsEmitter, } from "./observe.js";
11
13
  import { paramsFingerprint, PENDING_FINGERPRINT, queryFingerprint, useSelectedResult, useStableResult, } from "./select.js";
12
14
  /** Referentially stable across every pending render. */
@@ -99,17 +101,31 @@ function omitPathParams(query, route) {
99
101
  }
100
102
  return Object.fromEntries(Object.entries(query).filter(([key]) => !names.has(key)));
101
103
  }
104
+ /**
105
+ * Real-Next fallback for the adapter seam (design-16 TA3): the /testing
106
+ * provider overrides the read through {@link PagesNavigationContext}; with
107
+ * no provider mounted the context's `null` default resolves here, so
108
+ * production behavior (and this module's `next/router.js`-only bundle
109
+ * graph, per dist.test.ts) is unchanged. The adapter is resolved via an
110
+ * unconditional `useContext` BEFORE the try below, exactly where the direct
111
+ * call previously sat, keeping hook call order identical across renders and
112
+ * across provider presence (TA4).
113
+ */
114
+ const realPagesAdapter = { useRouter };
102
115
  /**
103
116
  * `useRouter` with the one failure the brand cannot catch translated (PR5):
104
117
  * in a hybrid project a component rendered under `app/` can legally hold a
105
118
  * pages-branded route, but `next/router` has no mount there and throws
106
119
  * "NextRouter was not mounted" — a message pointing at the wrong fix
107
120
  * (component placement is invisible to the type system). Rethrow a
108
- * `ParamourError` naming the actual mistake; everything else propagates.
121
+ * `ParamourError` naming the actual mistake; everything else propagates —
122
+ * and the adapter's `useRouter()` stays INSIDE the try so a testing
123
+ * adapter's unmounted reproduction gets the same translation.
109
124
  */
110
125
  function usePagesRouter() {
126
+ const nav = useContext(PagesNavigationContext) ?? realPagesAdapter;
111
127
  try {
112
- return useRouter();
128
+ return nav.useRouter();
113
129
  }
114
130
  catch (error) {
115
131
  if (error instanceof Error &&
@@ -0,0 +1,72 @@
1
+ import type { ParamsSource } from "paramour";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ /**
4
+ * `@paramour-js/next/testing` (design-16): a provider that overrides the
5
+ * hooks' framework reads through the adapter seam (TA1), so client
6
+ * components calling `useSearch`/`useRouteParams` (either flavor) can be
7
+ * unit-tested without runner-specific `next/*` module mocking. One provider
8
+ * feeds BOTH flavor contexts (TA5) — hybrid apps and pages components need
9
+ * no second import. Server code needs none of this: `parse`/`safeParse` and
10
+ * server components are pure functions over props (TA8).
11
+ *
12
+ * This module imports ONLY react and the (Next-free) adapter-seam module —
13
+ * no `next/*` specifier and no `@testing-library/*` (TA5; dist.test.ts pins
14
+ * the bundle graph, and the hermeticity check there is why these docs never
15
+ * spell out the two Next specifiers). It carries `"use client"` like
16
+ * app.ts.
17
+ *
18
+ * Stability contract (TA4): the provider holds ONE adapter pair for its
19
+ * lifetime, created once and closing over a latest-props ref that is
20
+ * reassigned every render — prop changes mutate what the stable adapters
21
+ * RETURN, they never mint new adapters, so the hooks' context reads stay
22
+ * identity-stable and mid-test URL changes are driven by ordinary rerenders
23
+ * with new props.
24
+ */
25
+ /**
26
+ * Input shape mirrors what Next hands the hooks (TA6) — no `url`
27
+ * reverse-matching in v1 (deferred: needs a core matcher). `params: null`
28
+ * and `mounted: false` are first-class because they are the two states
29
+ * nobody hand-rolling a mock models.
30
+ */
31
+ export interface ParamourTestingOptions {
32
+ /**
33
+ * Pages flavor only: `false` is the pre-hydration state of a
34
+ * statically-optimized page (`query` not yet populated — the PR5
35
+ * `pending` arm). Defaults to `true`. design-16 listed this as deferred,
36
+ * but TA7 migrates the whole pages suite — which pins the pending arm —
37
+ * to this provider, so it was promoted from the deferred list.
38
+ */
39
+ isReady?: boolean;
40
+ /**
41
+ * Pages flavor only: `false` reproduces the pages router's
42
+ * throw-on-unmounted state under `app/` (PR5) — pages.ts translates it to
43
+ * a `ParamourError` naming the actual mistake.
44
+ */
45
+ mounted?: boolean;
46
+ /** Captures `replace(href)` from either flavor's router. */
47
+ onReplace?: (href: string) => void;
48
+ /**
49
+ * `null` is the hybrid-app `useParams()` state outside an App-Router tree
50
+ * (Next #48058 family) and passes through as `null`; omitted means `{}`.
51
+ */
52
+ params?: null | ParamsSource;
53
+ /** Defaults to `"/"`. */
54
+ pathname?: string;
55
+ /** `"?page=2"` and `"page=2"` are both accepted. */
56
+ search?: string | URLSearchParams;
57
+ }
58
+ /**
59
+ * Renders BOTH flavor contexts' providers around `children` (TA5). Exported
60
+ * for people composing their own wrappers (Storybook decorators, custom
61
+ * render helpers); testing-library users want {@link withParamourTesting}.
62
+ */
63
+ export declare function ParamourTestingProvider(props: ParamourTestingOptions & {
64
+ children?: ReactNode;
65
+ }): ReactElement;
66
+ /**
67
+ * Wrapper-component form for testing-library's `wrapper` option (TA5,
68
+ * mirroring `withNuqsTestingAdapter`).
69
+ */
70
+ export declare function withParamourTesting(options?: ParamourTestingOptions): (props: {
71
+ children?: ReactNode;
72
+ }) => ReactElement;
@@ -0,0 +1,113 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useRef, useState } from "react";
4
+ import { AppNavigationContext, PagesNavigationContext, } from "./navigation-adapter.js";
5
+ /**
6
+ * Renders BOTH flavor contexts' providers around `children` (TA5). Exported
7
+ * for people composing their own wrappers (Storybook decorators, custom
8
+ * render helpers); testing-library users want {@link withParamourTesting}.
9
+ */
10
+ export function ParamourTestingProvider(props) {
11
+ // Latest-ref pattern (TA4): reassigned every render so the stable
12
+ // adapters below always read the CURRENT render's props.
13
+ const latest = useRef(props);
14
+ latest.current = props;
15
+ const [adapters] = useState(() => createAdapters(latest));
16
+ return (_jsx(AppNavigationContext.Provider, { value: adapters.app, children: _jsx(PagesNavigationContext.Provider, { value: adapters.pages, children: props.children }) }));
17
+ }
18
+ /**
19
+ * Wrapper-component form for testing-library's `wrapper` option (TA5,
20
+ * mirroring `withNuqsTestingAdapter`).
21
+ */
22
+ export function withParamourTesting(options = {}) {
23
+ return function ParamourTestingWrapper({ children, }) {
24
+ return (_jsx(ParamourTestingProvider, { ...options, children: children }));
25
+ };
26
+ }
27
+ /**
28
+ * The one adapter pair a provider instance ever holds (TA4). Every read
29
+ * defers to `latest.current`, so the adapters are stable while their
30
+ * answers track prop updates. Fresh `URLSearchParams` per call is fine —
31
+ * the hooks fingerprint the declared slice (SEL4), not the instance.
32
+ */
33
+ function createAdapters(latest) {
34
+ const app = {
35
+ useParams() {
36
+ const { params } = latest.current;
37
+ // `null` must pass through as-is — it is the hybrid-app state the
38
+ // app hooks deliberately tolerate; only OMITTED means `{}`.
39
+ return params === undefined ? {} : params;
40
+ },
41
+ usePathname() {
42
+ return latest.current.pathname ?? "/";
43
+ },
44
+ useRouter() {
45
+ return {
46
+ replace(href) {
47
+ latest.current.onReplace?.(href);
48
+ },
49
+ };
50
+ },
51
+ useSearchParams() {
52
+ return new URLSearchParams(normalizeSearch(latest.current.search));
53
+ },
54
+ };
55
+ const pages = {
56
+ useRouter() {
57
+ const options = latest.current;
58
+ if (options.mounted === false) {
59
+ // Verbatim prefix of next/router's real unmounted error — pages.ts
60
+ // matches on the message to translate it (PR5).
61
+ throw new Error("NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted");
62
+ }
63
+ const search = normalizeSearch(options.search);
64
+ return {
65
+ // asPath derives from pathname + normalized search (TA6) —
66
+ // basePath-relative, what the devtools navigate capability resolves
67
+ // against (DT8).
68
+ asPath: (options.pathname ?? "/") + (search === "" ? "" : `?${search}`),
69
+ isReady: options.isReady ?? true,
70
+ query: mergedQuery(options),
71
+ replace(url) {
72
+ latest.current.onReplace?.(url);
73
+ // Real next/router resolves `true` on a completed replace (TA6).
74
+ return Promise.resolve(true);
75
+ },
76
+ };
77
+ },
78
+ };
79
+ return { app, pages };
80
+ }
81
+ /**
82
+ * The merged `query` bag real Next hands the pages router: search entries
83
+ * first (single value → scalar, repeats → `string[]`), then `params`
84
+ * entries override — path params win, mirroring real Next's merge.
85
+ * Entries → fromEntries for define-semantics, so a hostile `"__proto__"`
86
+ * key stays an ordinary own property (omitPathParams's ethos in pages.ts).
87
+ */
88
+ function mergedQuery(options) {
89
+ const entries = [];
90
+ const searchParams = new URLSearchParams(normalizeSearch(options.search));
91
+ for (const key of new Set(searchParams.keys())) {
92
+ const values = searchParams.getAll(key);
93
+ const first = values[0];
94
+ // Unreachable for a key yielded by keys(); satisfies
95
+ // noUncheckedIndexedAccess without a cast.
96
+ if (first === undefined)
97
+ continue;
98
+ entries.push([key, values.length === 1 ? first : values]);
99
+ }
100
+ for (const entry of Object.entries(options.params ?? {})) {
101
+ entries.push(entry);
102
+ }
103
+ return Object.fromEntries(entries);
104
+ }
105
+ /** `undefined` → `""`; `URLSearchParams` → its string; strip one leading `?`. */
106
+ function normalizeSearch(search) {
107
+ if (search === undefined)
108
+ return "";
109
+ if (typeof search === "string") {
110
+ return search.startsWith("?") ? search.slice(1) : search;
111
+ }
112
+ return search.toString();
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paramour-js/next",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -17,6 +17,10 @@
17
17
  "./pages": {
18
18
  "types": "./dist/pages.d.ts",
19
19
  "default": "./dist/pages.js"
20
+ },
21
+ "./testing": {
22
+ "types": "./dist/testing.d.ts",
23
+ "default": "./dist/testing.js"
20
24
  }
21
25
  },
22
26
  "sideEffects": false,
@@ -30,7 +34,7 @@
30
34
  "jiti": "^2.7.0",
31
35
  "magicast": "^0.3.5",
32
36
  "tinyglobby": "^0.2.15",
33
- "paramour": "0.5.0"
37
+ "paramour": "0.5.1"
34
38
  },
35
39
  "peerDependencies": {
36
40
  "next": ">=15",
@@ -50,13 +54,23 @@
50
54
  },
51
55
  "description": "Next.js integration for paramour: withTypedRoutes, App and Pages Router hooks, PageProps glue, and the codegen CLI.",
52
56
  "author": "Jason Paff <jasonpaff@gmail.com>",
57
+ "keywords": [
58
+ "nextjs",
59
+ "app-router",
60
+ "typescript",
61
+ "routing",
62
+ "type-safe",
63
+ "typed-routes",
64
+ "cli",
65
+ "codegen"
66
+ ],
53
67
  "license": "MIT",
54
68
  "repository": {
55
69
  "type": "git",
56
70
  "url": "git+https://github.com/JasonPaff/paramour.git",
57
71
  "directory": "packages/next"
58
72
  },
59
- "homepage": "https://github.com/JasonPaff/paramour#readme",
73
+ "homepage": "https://paramour.dev/docs/reference/next",
60
74
  "bugs": {
61
75
  "url": "https://github.com/JasonPaff/paramour/issues"
62
76
  },