@bractjs/bractjs 0.1.26 → 0.1.27
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 +51 -32
- package/package.json +1 -1
- package/src/__tests__/build-path.test.ts +29 -0
- package/src/__tests__/codegen.test.ts +36 -0
- package/src/__tests__/typed-routing.test.ts +189 -0
- package/src/client/build-path.ts +24 -0
- package/src/client/components/Link.tsx +31 -8
- package/src/client/hooks/useNavigate.ts +46 -0
- package/src/client/hooks/useParams.ts +15 -4
- package/src/client/hooks/useSearchParams.ts +16 -6
- package/src/client/registry.ts +107 -0
- package/src/codegen/route-codegen.ts +62 -23
- package/src/index.ts +17 -0
- package/types/index.d.ts +47 -3
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ This README is a **step-by-step guide to every function and feature** BractJS ex
|
|
|
34
34
|
15. [Sessions: `createCookieSession`](#15-sessions)
|
|
35
35
|
16. [Lifecycle hooks: `defineLifecycle`](#16-lifecycle-hooks)
|
|
36
36
|
17. [Environment variables & `*.server.ts`](#17-environment-variables)
|
|
37
|
-
18. [Typed routes
|
|
37
|
+
18. [Typed routes](#18-typed-routes)
|
|
38
38
|
19. [Internationalization (i18n) utilities](#19-internationalization-utilities)
|
|
39
39
|
20. [Image optimization (`<Image>` + `/_image`)](#20-image-optimization)
|
|
40
40
|
21. [Build & run: CLI + programmatic API (`createDevServer`, `runBuild`, `loadUserConfig`)](#21-build--run)
|
|
@@ -353,10 +353,12 @@ const result = useActionData<{ error?: string }>();
|
|
|
353
353
|
```
|
|
354
354
|
|
|
355
355
|
### `useParams<T>()` → `T`
|
|
356
|
-
URL dynamic params. Pass a generic
|
|
356
|
+
URL dynamic params. Pass the **route pattern** as a generic to type the result against your codegen'd routes (see §18); an object shape also works.
|
|
357
357
|
```ts
|
|
358
|
-
const { id } = useParams<{ id: string }
|
|
358
|
+
const { id } = useParams<"/blog/:id">(); // { id: string } — typed from routes
|
|
359
|
+
const { id } = useParams<{ id: string }>(); // or a hand-written shape
|
|
359
360
|
```
|
|
361
|
+
> The pattern is supplied by the caller because the framework can't infer the active route at the type level (React Router's `useParams` works the same way).
|
|
360
362
|
|
|
361
363
|
### `useNavigation()` → `{ state }`
|
|
362
364
|
`"idle" | "loading" | "submitting"`.
|
|
@@ -365,12 +367,21 @@ const { state } = useNavigation();
|
|
|
365
367
|
if (state === "loading") return <Spinner />;
|
|
366
368
|
```
|
|
367
369
|
|
|
370
|
+
### `useNavigate()` → `(to, { params? }) => Promise<void>`
|
|
371
|
+
Imperative soft navigation — the counterpart to `<Link>`. `to` autocompletes your routes (after codegen, §18) and `params` is typed per route; any string is still accepted.
|
|
372
|
+
```ts
|
|
373
|
+
const navigate = useNavigate();
|
|
374
|
+
await navigate("/blog/:id", { params: { id: "42" } }); // typed
|
|
375
|
+
await navigate("/about"); // static
|
|
376
|
+
await navigate(`/blog/${id}`); // built string (also fine)
|
|
377
|
+
```
|
|
378
|
+
|
|
368
379
|
### `useSearchParams<T>()` → `{ searchParams, getParam, setSearchParams }`
|
|
369
|
-
Read/write URL query params; writing triggers a soft-nav loader re-run.
|
|
380
|
+
Read/write URL query params; writing triggers a soft-nav loader re-run. Pass the route pattern as a generic to type the result against `RouteSearchParamsMap` (augment it per route, §18); an object shape also works.
|
|
370
381
|
```ts
|
|
371
|
-
const { searchParams, getParam, setSearchParams } = useSearchParams<
|
|
372
|
-
const q = getParam("q");
|
|
373
|
-
setSearchParams({ q: "bun" });
|
|
382
|
+
const { searchParams, getParam, setSearchParams } = useSearchParams<"/blog/:id">();
|
|
383
|
+
const q = getParam("q"); // string | null
|
|
384
|
+
setSearchParams({ q: "bun" }); // replace all params
|
|
374
385
|
setSearchParams((prev) => { prev.set("page", "2"); return prev; }); // update
|
|
375
386
|
```
|
|
376
387
|
|
|
@@ -415,12 +426,13 @@ export default function BlogLayout() {
|
|
|
415
426
|
}
|
|
416
427
|
```
|
|
417
428
|
|
|
418
|
-
### `<Link to prefetch? viewTransition?>`
|
|
419
|
-
Soft-navigates without a full reload.
|
|
429
|
+
### `<Link to params? prefetch? viewTransition?>`
|
|
430
|
+
Soft-navigates without a full reload. After codegen (§18), `to` autocompletes your routes; for a dynamic route pass typed `params`. Building the URL yourself still works, so existing links need no changes.
|
|
420
431
|
```tsx
|
|
421
|
-
<Link to="/blog
|
|
422
|
-
<Link to=
|
|
423
|
-
<Link to="/
|
|
432
|
+
<Link to="/blog/:id" params={{ id: "42" }}>Read</Link> {/* typed route + params */}
|
|
433
|
+
<Link to={`/blog/${id}`}>Read</Link> {/* built string — also fine */}
|
|
434
|
+
<Link to="/about" prefetch="hover">About</Link> {/* preload chunk + loader on hover */}
|
|
435
|
+
<Link to="/gallery" viewTransition>Gallery</Link> {/* use View Transitions API */}
|
|
424
436
|
```
|
|
425
437
|
Modifier-clicks (ctrl/cmd/shift/alt) fall back to native browser navigation.
|
|
426
438
|
|
|
@@ -738,48 +750,55 @@ On the server, read env via `Bun.env.*` directly.
|
|
|
738
750
|
|
|
739
751
|
---
|
|
740
752
|
|
|
741
|
-
## 18. Typed routes
|
|
753
|
+
## 18. Typed routes
|
|
742
754
|
|
|
743
|
-
Generate
|
|
755
|
+
Generate type-safe routing from your route files — one command wires `<Link>`, `useNavigate`, `useParams`, and `useSearchParams` to your actual routes.
|
|
744
756
|
|
|
745
757
|
```sh
|
|
746
758
|
bractjs codegen # ./app → ./app/route-types.gen.ts
|
|
747
759
|
bractjs codegen ./app ./app/types.ts # explicit paths
|
|
748
760
|
```
|
|
749
761
|
|
|
750
|
-
Runs automatically during `bractjs build`.
|
|
762
|
+
Runs automatically during `bractjs build`. Make sure the generated file is part of your TypeScript program (it is, if your `tsconfig.json` `include`s `app/`). It augments BractJS's `Register` interface, after which the runtime components and hooks become type-safe — **no per-route imports needed**:
|
|
751
763
|
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
T extends "/blog/:id" ? { id: string } : Record<never, never>;
|
|
764
|
+
```tsx
|
|
765
|
+
<Link to="/blog/:id" params={{ id }} /> // ✅ "/blog/:id" autocompletes; params typed
|
|
766
|
+
<Link to="/blgo/:id" params={{ id }} /> // ❌ typo'd route — compile error
|
|
767
|
+
<Link to="/blog/:id" params={{ x: id }} /> // ❌ wrong param key — compile error
|
|
757
768
|
|
|
758
|
-
|
|
759
|
-
|
|
769
|
+
const navigate = useNavigate();
|
|
770
|
+
navigate("/blog/:id", { params: { id } }); // ✅ same typing as <Link>
|
|
760
771
|
|
|
761
|
-
|
|
762
|
-
"/": () => "/",
|
|
763
|
-
"/blog/:id": (p: { id: string }) => `/blog/${p.id}`,
|
|
764
|
-
} as const;
|
|
772
|
+
const { id } = useParams<"/blog/:id">(); // id: string
|
|
765
773
|
```
|
|
766
774
|
|
|
767
|
-
|
|
775
|
+
Building the URL yourself (`<Link to={`/blog/${id}`}>`) still type-checks, so adopting codegen never breaks existing links.
|
|
776
|
+
|
|
777
|
+
The generated file also exports types/helpers for typed loaders and explicit URL building:
|
|
768
778
|
|
|
769
779
|
```ts
|
|
770
|
-
import type { TypedLoaderArgs
|
|
780
|
+
import type { TypedLoaderArgs } from "../route-types.gen.ts";
|
|
771
781
|
import { routes } from "../route-types.gen.ts";
|
|
772
782
|
|
|
773
783
|
export async function loader({ params }: TypedLoaderArgs<"/blog/:id">) {
|
|
774
|
-
return db.post.findById(params.id);
|
|
784
|
+
return db.post.findById(params.id); // params.id: string
|
|
775
785
|
}
|
|
786
|
+
routes["/blog/:id"]({ id: "123" }); // → "/blog/123" (typo'd routes won't compile)
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
**Type a route's search params or context** by augmenting the package interfaces — `SearchParams<T>` / `Context<T>` and `useSearchParams<T>()` pick it up:
|
|
776
790
|
|
|
777
|
-
|
|
778
|
-
|
|
791
|
+
```ts
|
|
792
|
+
declare module "@bractjs/bractjs" {
|
|
793
|
+
interface RouteSearchParamsMap { "/blog": { page: string; sort: string } }
|
|
794
|
+
interface RouteContextMap { "/admin": { user: { id: string; role: "admin" } } }
|
|
795
|
+
}
|
|
779
796
|
```
|
|
780
797
|
|
|
781
798
|
You can also call `writeRouteTypes(appDir, outPath?)` / `generateRouteTypes(appDir)` programmatically.
|
|
782
799
|
|
|
800
|
+
> **Heads up:** earlier versions documented `useParams<RouteParams<"/blog/:id">>()` and untyped `<Link to={string}>`. The route-literal form (`useParams<"/blog/:id">()`) and typed `<Link>`/`useNavigate` are the current API; the old forms still compile.
|
|
801
|
+
|
|
783
802
|
---
|
|
784
803
|
|
|
785
804
|
## 19. Internationalization utilities
|
|
@@ -1098,7 +1117,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
|
1098
1117
|
- **Streaming SSR** — `renderToReadableStream()` with `defer()` and `<Await>`.
|
|
1099
1118
|
- **File-based routing** — drop a file in `app/routes/`.
|
|
1100
1119
|
- **Full-stack** — loaders, actions, sessions, server actions, typed API routes, middleware.
|
|
1101
|
-
- **Typed routes** — codegen
|
|
1120
|
+
- **Typed routes** — codegen wires `<Link>`, `useNavigate`, and `useParams` to your routes (autocompleted paths, typed params), plus a type-safe URL builder.
|
|
1102
1121
|
- **Single-binary** — `bun build --compile` to one executable.
|
|
1103
1122
|
|
|
1104
1123
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/bractjs/bractjs#readme",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { buildPath } from "../client/build-path.ts";
|
|
3
|
+
|
|
4
|
+
describe("buildPath", () => {
|
|
5
|
+
test("substitutes a single :param", () => {
|
|
6
|
+
expect(buildPath("/blog/:id", { id: "42" })).toBe("/blog/42");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("substitutes multiple params", () => {
|
|
10
|
+
expect(buildPath("/u/:user/post/:post", { user: "ann", post: "7" })).toBe("/u/ann/post/7");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("passes static patterns through untouched", () => {
|
|
14
|
+
expect(buildPath("/about", {})).toBe("/about");
|
|
15
|
+
expect(buildPath("/", {})).toBe("/");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("URL-encodes param values", () => {
|
|
19
|
+
expect(buildPath("/search/:q", { q: "a b/c" })).toBe("/search/a%20b%2Fc");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("coerces numbers to strings", () => {
|
|
23
|
+
expect(buildPath("/n/:id", { id: 7 })).toBe("/n/7");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("leaves an absent param's segment intact (surfaces as an obvious bad URL)", () => {
|
|
27
|
+
expect(buildPath("/blog/:id", {})).toBe("/blog/:id");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -29,6 +29,42 @@ describe("route-codegen — output shape", () => {
|
|
|
29
29
|
expect(out).toMatch(/\| "\/users\/:id"/);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
test("wires the Register augmentation for typed routing", async () => {
|
|
33
|
+
const regApp = join(tmpdir(), `bract-codegen-register-${Date.now()}`);
|
|
34
|
+
await mkdir(join(regApp, "routes", "users"), { recursive: true });
|
|
35
|
+
await writeFile(join(regApp, "routes", "about.tsx"), "export default () => null;");
|
|
36
|
+
await writeFile(join(regApp, "routes", "users", "[id].tsx"), "export default () => null;");
|
|
37
|
+
|
|
38
|
+
const out = await generateRouteTypes(regApp);
|
|
39
|
+
|
|
40
|
+
// Bug 1 regression: the augmentation must target the real package name.
|
|
41
|
+
expect(out).toContain('declare module "@bractjs/bractjs"');
|
|
42
|
+
expect(out).not.toMatch(/declare module ['"]bractjs['"]/);
|
|
43
|
+
|
|
44
|
+
// Bug 2 regression: the customization maps are AUGMENTED on the package, not
|
|
45
|
+
// re-declared as bare top-level interfaces in the app file.
|
|
46
|
+
expect(out).not.toMatch(/^export interface RouteSearchParamsMap/m);
|
|
47
|
+
expect(out).not.toMatch(/^export interface RouteContextMap/m);
|
|
48
|
+
expect(out).toContain('import type { RouteSearchParamsMap, RouteContextMap } from "@bractjs/bractjs"');
|
|
49
|
+
|
|
50
|
+
// The Register seam carries the route union and a per-route params map.
|
|
51
|
+
expect(out).toContain("interface Register {");
|
|
52
|
+
expect(out).toContain("routes: AppRoutes;");
|
|
53
|
+
expect(out).toMatch(/"\/users\/:id": \{ id: string \};/); // dynamic route → typed params
|
|
54
|
+
expect(out).toMatch(/"\/about": \{\};/); // static route → no params
|
|
55
|
+
|
|
56
|
+
await rm(regApp, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("emits no Register augmentation when there are no routes", async () => {
|
|
60
|
+
const emptyApp = join(tmpdir(), `bract-codegen-empty-${Date.now()}`);
|
|
61
|
+
await mkdir(join(emptyApp, "routes"), { recursive: true });
|
|
62
|
+
const out = await generateRouteTypes(emptyApp);
|
|
63
|
+
expect(out).toContain("export type AppRoutes =\n never;");
|
|
64
|
+
expect(out).not.toContain("interface Register {");
|
|
65
|
+
await rm(emptyApp, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
32
68
|
test("rejects hostile filenames at codegen time", async () => {
|
|
33
69
|
const hostileApp = join(tmpdir(), `bract-codegen-hostile-${Date.now()}`);
|
|
34
70
|
await mkdir(join(hostileApp, "routes"), { recursive: true });
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level guardrail for end-to-end typed routing.
|
|
3
|
+
*
|
|
4
|
+
* The runtime helpers (`<Link>`, `useNavigate`, `useParams`, `useSearchParams`)
|
|
5
|
+
* gain their type-safety from a declaration-merging seam: `bractjs codegen`
|
|
6
|
+
* augments the package's `Register` interface, and the helpers resolve route
|
|
7
|
+
* types from it. None of that is observable at runtime — only `tsc` proves it.
|
|
8
|
+
* This test writes a fixture, generates its `route-types.gen.ts`, and runs
|
|
9
|
+
* `tsc --noEmit` over:
|
|
10
|
+
*
|
|
11
|
+
* - positive usage (typed <Link>/useNavigate/useParams must compile), and
|
|
12
|
+
* - negative usage guarded by `@ts-expect-error` (bad params/routes MUST error,
|
|
13
|
+
* so a directive that goes unused fails the build), and
|
|
14
|
+
* - a SECOND fixture with NO codegen, proving un-registered apps still compile
|
|
15
|
+
* with the loose `string` fallback (the backwards-compat contract).
|
|
16
|
+
*
|
|
17
|
+
* It guards the subtle failure mode that nearly shipped: a too-clever resolution
|
|
18
|
+
* type silently falling back to loose `string`, which compiles but enforces
|
|
19
|
+
* nothing.
|
|
20
|
+
*
|
|
21
|
+
* The fixture lives inside the repo (`.tmp-types-*`, gitignored) so its
|
|
22
|
+
* `@bractjs/bractjs` import self-resolves to the in-repo framework via the root
|
|
23
|
+
* tsconfig `paths` mapping.
|
|
24
|
+
*/
|
|
25
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
26
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
27
|
+
import { resolve, join } from "node:path";
|
|
28
|
+
import { generateRouteTypes } from "../codegen/route-codegen.ts";
|
|
29
|
+
|
|
30
|
+
const REPO_ROOT = resolve(import.meta.dir, "../..");
|
|
31
|
+
const TMP = resolve(import.meta.dir, `.tmp-types-${Date.now()}`);
|
|
32
|
+
|
|
33
|
+
// Invoke TypeScript via `bunx tsc` — it works whether tsc is installed locally
|
|
34
|
+
// or fetched on demand. (A direct node_modules/.bin/tsc path is unreliable here:
|
|
35
|
+
// it may be a dangling symlink to an un-installed package.)
|
|
36
|
+
const TSC_CMD = ["bunx", "tsc"] as const;
|
|
37
|
+
|
|
38
|
+
// A fixture tsconfig that resolves `@bractjs/bractjs` → the in-repo entry, mirrors
|
|
39
|
+
// the example apps' compiler options, and type-checks only this fixture's files.
|
|
40
|
+
function tsconfig(includeGlobDir: string): string {
|
|
41
|
+
return JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
compilerOptions: {
|
|
44
|
+
target: "ESNext",
|
|
45
|
+
module: "ESNext",
|
|
46
|
+
moduleResolution: "bundler",
|
|
47
|
+
lib: ["ESNext", "DOM", "DOM.Iterable"],
|
|
48
|
+
jsx: "react-jsx",
|
|
49
|
+
jsxImportSource: "react",
|
|
50
|
+
strict: true,
|
|
51
|
+
noEmit: true,
|
|
52
|
+
skipLibCheck: true,
|
|
53
|
+
allowImportingTsExtensions: true,
|
|
54
|
+
types: ["react", "react-dom"],
|
|
55
|
+
// `paths` with absolute targets needs no `baseUrl` (and baseUrl is
|
|
56
|
+
// deprecated as of TS6, which would fail the compile here).
|
|
57
|
+
paths: { "@bractjs/bractjs": [join(REPO_ROOT, "src/index.ts")] },
|
|
58
|
+
},
|
|
59
|
+
include: [join(includeGlobDir, "**/*.ts"), join(includeGlobDir, "**/*.tsx")],
|
|
60
|
+
},
|
|
61
|
+
null,
|
|
62
|
+
2,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runTsc(projectDir: string): Promise<{ code: number; output: string }> {
|
|
67
|
+
const proc = Bun.spawn([...TSC_CMD, "--noEmit", "-p", join(projectDir, "tsconfig.json")], {
|
|
68
|
+
cwd: projectDir,
|
|
69
|
+
stdout: "pipe",
|
|
70
|
+
stderr: "pipe",
|
|
71
|
+
});
|
|
72
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
73
|
+
new Response(proc.stdout).text(),
|
|
74
|
+
new Response(proc.stderr).text(),
|
|
75
|
+
proc.exited,
|
|
76
|
+
]);
|
|
77
|
+
return { code, output: stdout + stderr };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let tscAvailable = false;
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
await mkdir(TMP, { recursive: true });
|
|
84
|
+
// Real availability probe: actually run the compiler. Earlier this checked a
|
|
85
|
+
// symlink's existence and silently skipped when it dangled — making the whole
|
|
86
|
+
// suite a no-op that still "passed". Run `tsc --version` and trust the exit.
|
|
87
|
+
try {
|
|
88
|
+
const probe = Bun.spawn([...TSC_CMD, "--version"], { stdout: "pipe", stderr: "pipe" });
|
|
89
|
+
const out = await new Response(probe.stdout).text();
|
|
90
|
+
tscAvailable = (await probe.exited) === 0 && /Version/.test(out);
|
|
91
|
+
} catch {
|
|
92
|
+
tscAvailable = false;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(async () => {
|
|
97
|
+
await rm(TMP, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("typed routing (type-level)", () => {
|
|
101
|
+
test("tsc is available (else the type-level assertions below are skipped)", () => {
|
|
102
|
+
// Surfaced as its own test so a skipped type-check is visible in the report
|
|
103
|
+
// rather than masquerading as a silent pass.
|
|
104
|
+
if (!tscAvailable) console.warn("[typed-routing] tsc unavailable — type-level checks skipped");
|
|
105
|
+
expect(typeof tscAvailable).toBe("boolean");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("registered app: typed Link/useNavigate/useParams compile; bad usage errors", async () => {
|
|
109
|
+
if (!tscAvailable) return; // tsc not available in this environment — skip gracefully
|
|
110
|
+
|
|
111
|
+
const app = join(TMP, "registered");
|
|
112
|
+
await mkdir(join(app, "routes", "blog"), { recursive: true });
|
|
113
|
+
await writeFile(join(app, "routes", "_index.tsx"), "export default () => null;\n");
|
|
114
|
+
await writeFile(join(app, "routes", "blog", "[id].tsx"), "export default () => null;\n");
|
|
115
|
+
|
|
116
|
+
// Generate the registration file (augments Register on the package).
|
|
117
|
+
await writeFile(join(app, "route-types.gen.ts"), await generateRouteTypes(app));
|
|
118
|
+
await writeFile(join(app, "tsconfig.json"), tsconfig("."));
|
|
119
|
+
|
|
120
|
+
await writeFile(
|
|
121
|
+
join(app, "usage.tsx"),
|
|
122
|
+
`import { Link, useNavigate, useParams, useSearchParams } from "@bractjs/bractjs";\n` +
|
|
123
|
+
`import "./route-types.gen.ts";\n` +
|
|
124
|
+
`export function Ok() {\n` +
|
|
125
|
+
` const navigate = useNavigate();\n` +
|
|
126
|
+
` const p = useParams<"/blog/:id">();\n` +
|
|
127
|
+
` const id: string = p.id;\n` +
|
|
128
|
+
` useSearchParams<"/blog/:id">();\n` +
|
|
129
|
+
` return (<>\n` +
|
|
130
|
+
` <Link to="/blog/:id" params={{ id }}>typed</Link>\n` +
|
|
131
|
+
` <Link to="/">static literal</Link>\n` +
|
|
132
|
+
` <Link to={\`/\${id}\`}>built string (BC)</Link>\n` +
|
|
133
|
+
` <button onClick={() => { void navigate("/blog/:id", { params: { id } }); }}>go</button>\n` +
|
|
134
|
+
` <button onClick={() => { void navigate("/"); }}>home</button>\n` +
|
|
135
|
+
` </>);\n` +
|
|
136
|
+
`}\n` +
|
|
137
|
+
`export function Bad() {\n` +
|
|
138
|
+
` const navigate = useNavigate();\n` +
|
|
139
|
+
` const p = useParams<"/blog/:id">();\n` +
|
|
140
|
+
` return (<>\n` +
|
|
141
|
+
` {/* @ts-expect-error wrong param key */}\n` +
|
|
142
|
+
` <Link to="/blog/:id" params={{ wrong: "1" }}>x</Link>\n` +
|
|
143
|
+
` {/* @ts-expect-error missing required param */}\n` +
|
|
144
|
+
` <Link to="/blog/:id" params={{}}>x</Link>\n` +
|
|
145
|
+
` <button onClick={() => {\n` +
|
|
146
|
+
` // @ts-expect-error wrong param key in navigate\n` +
|
|
147
|
+
` void navigate("/blog/:id", { params: { wrong: "1" } });\n` +
|
|
148
|
+
` }}>go</button>\n` +
|
|
149
|
+
` {/* @ts-expect-error /blog/:id has no \`nope\` param */}\n` +
|
|
150
|
+
` <span>{p.nope}</span>\n` +
|
|
151
|
+
` </>);\n` +
|
|
152
|
+
`}\n`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const { code, output } = await runTsc(app);
|
|
156
|
+
// Exit 0 means: positives compiled AND every @ts-expect-error was satisfied
|
|
157
|
+
// by a real error. A silently-loose type would leave directives unused → TS2578.
|
|
158
|
+
// (`output` may carry bunx "Resolving dependencies" noise — key on TS errors.)
|
|
159
|
+
expect(output).not.toContain("TS2578"); // unused @ts-expect-error → typing too loose
|
|
160
|
+
expect(output).not.toMatch(/error TS/);
|
|
161
|
+
expect(code).toBe(0);
|
|
162
|
+
}, 60_000);
|
|
163
|
+
|
|
164
|
+
test("un-registered app (no codegen): loose string fallback still compiles", async () => {
|
|
165
|
+
if (!tscAvailable) return;
|
|
166
|
+
|
|
167
|
+
const app = join(TMP, "loose");
|
|
168
|
+
await mkdir(app, { recursive: true });
|
|
169
|
+
await writeFile(join(app, "tsconfig.json"), tsconfig("."));
|
|
170
|
+
// No route-types.gen.ts → Register stays empty → everything falls back to string.
|
|
171
|
+
await writeFile(
|
|
172
|
+
join(app, "usage.tsx"),
|
|
173
|
+
`import { Link, useNavigate, useParams } from "@bractjs/bractjs";\n` +
|
|
174
|
+
`export function App({ href }: { href: string }) {\n` +
|
|
175
|
+
` const navigate = useNavigate();\n` +
|
|
176
|
+
` const p = useParams();\n` +
|
|
177
|
+
` return (<>\n` +
|
|
178
|
+
` <Link to={href}>any string</Link>\n` +
|
|
179
|
+
` <Link to="/anything/at/all">arbitrary literal</Link>\n` +
|
|
180
|
+
` <button onClick={() => { void navigate("/wherever"); }}>{p.x}</button>\n` +
|
|
181
|
+
` </>);\n` +
|
|
182
|
+
`}\n`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const { code, output } = await runTsc(app);
|
|
186
|
+
expect(output).not.toMatch(/error TS/);
|
|
187
|
+
expect(code).toBe(0);
|
|
188
|
+
}, 60_000);
|
|
189
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Substitute `:name` segments in a colon-style route pattern with param values.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the substitution `bractjs codegen` bakes into the generated `routes`
|
|
4
|
+
// builder (`src/codegen/route-codegen.ts`). The framework's own `<Link>` /
|
|
5
|
+
// `useNavigate` can't import that app-local generated object, so they share this
|
|
6
|
+
// helper instead. Values are URL-encoded; an absent param leaves its `:name`
|
|
7
|
+
// segment intact (surfaced as an obviously-wrong URL rather than silently dropped).
|
|
8
|
+
//
|
|
9
|
+
// Patterns without a `:` (static routes, or already-built hrefs) pass straight
|
|
10
|
+
// through, so this is safe to call unconditionally.
|
|
11
|
+
export function buildPath(
|
|
12
|
+
pattern: string,
|
|
13
|
+
params: Record<string, string | number>,
|
|
14
|
+
): string {
|
|
15
|
+
if (!pattern.includes(":")) return pattern;
|
|
16
|
+
return pattern
|
|
17
|
+
.split("/")
|
|
18
|
+
.map((seg) => {
|
|
19
|
+
if (!seg.startsWith(":")) return seg;
|
|
20
|
+
const value = params[seg.slice(1)];
|
|
21
|
+
return value === undefined ? seg : encodeURIComponent(String(value));
|
|
22
|
+
})
|
|
23
|
+
.join("/");
|
|
24
|
+
}
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import { useContext, type AnchorHTMLAttributes, type ReactNode } from "react";
|
|
2
2
|
import { NavigationContext, RouterContext } from "../router.tsx";
|
|
3
3
|
import { prefetchRoute } from "../prefetch.ts";
|
|
4
|
+
import { buildPath } from "../build-path.ts";
|
|
5
|
+
import type { RegisteredRoutes, ParamsFor } from "../registry.ts";
|
|
4
6
|
|
|
5
7
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
// `to` accepts any registered route literal (autocomplete + typed `params`) but
|
|
10
|
+
// also any string via `(string & {})`, so existing call sites that build the URL
|
|
11
|
+
// themselves — `to={`/posts/${slug}`}`, `to={item.href}` — keep compiling. Run
|
|
12
|
+
// `bractjs codegen` to register the app's routes and unlock autocomplete; until
|
|
13
|
+
// then `RegisteredRoutes` is `string` and this is just today's loose prop.
|
|
14
|
+
type LinkProps<TTo extends RegisteredRoutes = RegisteredRoutes> = Omit<
|
|
15
|
+
AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
16
|
+
"href"
|
|
17
|
+
> & {
|
|
18
|
+
to: TTo | (string & {});
|
|
19
|
+
/** Path params for a dynamic `to` (e.g. `params={{ id }}` for `/blog/:id`). */
|
|
20
|
+
params?: ParamsFor<TTo>;
|
|
9
21
|
prefetch?: "hover" | "none";
|
|
10
22
|
/** Opt in to View Transitions API for this navigation (E1). */
|
|
11
23
|
viewTransition?: boolean;
|
|
12
24
|
children: ReactNode;
|
|
13
|
-
}
|
|
25
|
+
};
|
|
14
26
|
|
|
15
27
|
// ── Component ──────────────────────────────────────────────────────────────
|
|
16
28
|
|
|
@@ -19,11 +31,22 @@ const supportsViewTransitions =
|
|
|
19
31
|
typeof document !== "undefined" &&
|
|
20
32
|
typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === "function";
|
|
21
33
|
|
|
22
|
-
export function Link
|
|
34
|
+
export function Link<TTo extends RegisteredRoutes = RegisteredRoutes>({
|
|
35
|
+
to,
|
|
36
|
+
params,
|
|
37
|
+
prefetch = "none",
|
|
38
|
+
viewTransition = false,
|
|
39
|
+
children,
|
|
40
|
+
...rest
|
|
41
|
+
}: LinkProps<TTo>) {
|
|
23
42
|
const navCtx = useContext(NavigationContext);
|
|
24
43
|
const routerCtx = useContext(RouterContext);
|
|
25
44
|
const isLoading = navCtx?.state === "loading";
|
|
26
45
|
|
|
46
|
+
// Resolve the final href once: substitute params into a dynamic pattern, or
|
|
47
|
+
// pass an already-built string straight through.
|
|
48
|
+
const href = params ? buildPath(to as string, params as Record<string, string>) : (to as string);
|
|
49
|
+
|
|
27
50
|
function handleClick(e: React.MouseEvent<HTMLAnchorElement>) {
|
|
28
51
|
if (!navCtx) return; // SSR: let browser handle naturally
|
|
29
52
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
|
@@ -31,20 +54,20 @@ export function Link({ to, prefetch = "none", viewTransition = false, children,
|
|
|
31
54
|
|
|
32
55
|
if (viewTransition && supportsViewTransitions) {
|
|
33
56
|
(document as Document & { startViewTransition(cb: () => void): void }).startViewTransition(
|
|
34
|
-
() => { void navCtx.navigate(
|
|
57
|
+
() => { void navCtx.navigate(href); },
|
|
35
58
|
);
|
|
36
59
|
} else {
|
|
37
|
-
void navCtx.navigate(
|
|
60
|
+
void navCtx.navigate(href);
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
function handleMouseEnter() {
|
|
42
|
-
if (prefetch === "hover" && routerCtx) prefetchRoute(
|
|
65
|
+
if (prefetch === "hover" && routerCtx) prefetchRoute(href, routerCtx.manifest);
|
|
43
66
|
}
|
|
44
67
|
|
|
45
68
|
return (
|
|
46
69
|
<a
|
|
47
|
-
href={
|
|
70
|
+
href={href}
|
|
48
71
|
onClick={handleClick}
|
|
49
72
|
onMouseEnter={handleMouseEnter}
|
|
50
73
|
aria-disabled={isLoading || undefined}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useContext, useCallback } from "react";
|
|
2
|
+
import { NavigationContext } from "../router.tsx";
|
|
3
|
+
import { buildPath } from "../build-path.ts";
|
|
4
|
+
import type { RegisteredRoutes, ParamsFor } from "../registry.ts";
|
|
5
|
+
|
|
6
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface NavigateOptions<TTo extends RegisteredRoutes = RegisteredRoutes> {
|
|
9
|
+
/** Path params for a dynamic `to` (e.g. `{ params: { id } }` for `/blog/:id`). */
|
|
10
|
+
params?: ParamsFor<TTo>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface NavigateFn {
|
|
14
|
+
<TTo extends RegisteredRoutes>(
|
|
15
|
+
to: TTo | (string & {}),
|
|
16
|
+
options?: NavigateOptions<TTo>,
|
|
17
|
+
): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns a typed `navigate(to, { params })` for programmatic soft navigation —
|
|
24
|
+
* the imperative counterpart to `<Link>`. Mirrors `<Link>`'s `to`/`params` API:
|
|
25
|
+
* `to` autocompletes registered routes (after `bractjs codegen`) while still
|
|
26
|
+
* accepting any string, and `params` is typed per route.
|
|
27
|
+
*
|
|
28
|
+
* SSR-safe and safe outside a `ClientRouter`: with no NavigationContext it
|
|
29
|
+
* resolves to a no-op (same guard as `<Link>`), so it never throws during render.
|
|
30
|
+
*
|
|
31
|
+
* Note: navigation always pushes a history entry today; a `replace` option will
|
|
32
|
+
* follow once the underlying navigate contract supports it.
|
|
33
|
+
*/
|
|
34
|
+
export function useNavigate(): NavigateFn {
|
|
35
|
+
const navCtx = useContext(NavigationContext);
|
|
36
|
+
return useCallback<NavigateFn>(
|
|
37
|
+
(to, options) => {
|
|
38
|
+
const href = options?.params
|
|
39
|
+
? buildPath(to as string, options.params as Record<string, string>)
|
|
40
|
+
: (to as string);
|
|
41
|
+
if (!navCtx) return Promise.resolve();
|
|
42
|
+
return navCtx.navigate(href);
|
|
43
|
+
},
|
|
44
|
+
[navCtx],
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { useContext } from "react";
|
|
2
2
|
import { RouterContext } from "../router.tsx";
|
|
3
3
|
import { BractJSContext } from "../../shared/context.ts";
|
|
4
|
+
import type { ParamsFor } from "../registry.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Returns the current route's URL params (e.g. { id: "42" }).
|
|
7
|
-
*
|
|
7
|
+
* Returns the current route's URL params (e.g. `{ id: "42" }`).
|
|
8
|
+
*
|
|
9
|
+
* Pass the route pattern as a generic to type the result against your codegen'd
|
|
10
|
+
* routes: `useParams<"/blog/:id">()` → `{ id: string }`. The pattern is supplied
|
|
11
|
+
* by the caller because the framework can't infer the active route at the type
|
|
12
|
+
* level (React Router's `useParams` has the same limitation). An object generic
|
|
13
|
+
* — `useParams<{ id: string }>()` — also works for hand-typed shapes.
|
|
14
|
+
*
|
|
8
15
|
* Works in both SSR and client contexts.
|
|
9
16
|
*/
|
|
10
|
-
|
|
17
|
+
// Overload 1: a route literal → params resolved from the registry.
|
|
18
|
+
export function useParams<TTo extends string>(): ParamsFor<TTo>;
|
|
19
|
+
// Overload 2: an explicit object shape (back-compat with the old generic form).
|
|
20
|
+
export function useParams<T extends Record<string, string> = Record<string, string>>(): T;
|
|
21
|
+
export function useParams(): Record<string, string> {
|
|
11
22
|
const router = useContext(RouterContext);
|
|
12
23
|
const bract = useContext(BractJSContext);
|
|
13
|
-
return
|
|
24
|
+
return router?.params ?? bract?.params ?? {};
|
|
14
25
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef, startTransition } from "react";
|
|
2
2
|
import { NavigationContext } from "../router.tsx";
|
|
3
3
|
import { useContext } from "react";
|
|
4
|
+
import type { SearchFor } from "../registry.ts";
|
|
4
5
|
|
|
5
6
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
7
|
|
|
@@ -17,13 +18,22 @@ export interface SearchParamsResult<T extends Record<string, string>> {
|
|
|
17
18
|
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* Reads and writes URL search params
|
|
21
|
-
*
|
|
21
|
+
* Reads and writes URL search params. Triggers a loader re-run (soft-nav fetch)
|
|
22
|
+
* when params change.
|
|
23
|
+
*
|
|
24
|
+
* Pass the route pattern as a generic to type the result against your codegen'd
|
|
25
|
+
* routes: `useSearchParams<"/posts">()`. Augment `RouteSearchParamsMap` to give a
|
|
26
|
+
* route a concrete shape (defaults to `Record<string, string>`). The pattern is
|
|
27
|
+
* supplied by the caller — the framework can't infer the active route at the type
|
|
28
|
+
* level. An object generic — `useSearchParams<{ page: string }>()` — also works.
|
|
22
29
|
*
|
|
23
|
-
* T is the route's SearchParams shape (e.g. { page: string; sort: string }).
|
|
24
30
|
* This hook is SSR-safe: on the server window is absent, so it returns empty params.
|
|
25
31
|
*/
|
|
26
|
-
|
|
32
|
+
// Overload 1: a route literal → search shape resolved from the registry.
|
|
33
|
+
export function useSearchParams<TTo extends string>(): SearchParamsResult<SearchFor<TTo>>;
|
|
34
|
+
// Overload 2: an explicit object shape (back-compat with the old generic form).
|
|
35
|
+
export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
|
|
36
|
+
export function useSearchParams(): SearchParamsResult<Record<string, string>> {
|
|
27
37
|
const navCtx = useContext(NavigationContext);
|
|
28
38
|
|
|
29
39
|
function readCurrent(): URLSearchParams {
|
|
@@ -66,8 +76,8 @@ export function useSearchParams<T extends Record<string, string> = Record<string
|
|
|
66
76
|
}
|
|
67
77
|
}, [navCtx]);
|
|
68
78
|
|
|
69
|
-
const getParam = useCallback(
|
|
70
|
-
return
|
|
79
|
+
const getParam = useCallback((key: string): string | null => {
|
|
80
|
+
return searchParams.get(key);
|
|
71
81
|
}, [searchParams]);
|
|
72
82
|
|
|
73
83
|
return { searchParams, getParam, setSearchParams };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Type-registration seam for end-to-end typed routing (TanStack-Router style).
|
|
2
|
+
//
|
|
3
|
+
// This file is RUNTIME-FREE — it contributes only types. The runtime helpers
|
|
4
|
+
// (`<Link>`, `useNavigate`, `useParams`, `useSearchParams`) resolve their route
|
|
5
|
+
// types from `Register` declared here.
|
|
6
|
+
//
|
|
7
|
+
// HOW IT WORKS
|
|
8
|
+
// ------------
|
|
9
|
+
// `Register` is an empty, augmentable interface. Running `bractjs codegen`
|
|
10
|
+
// writes `app/route-types.gen.ts`, which augments it:
|
|
11
|
+
//
|
|
12
|
+
// declare module "@bractjs/bractjs" {
|
|
13
|
+
// interface Register {
|
|
14
|
+
// routes: {
|
|
15
|
+
// routes: "/" | "/blog/:id";
|
|
16
|
+
// params: { "/blog/:id": { id: string } };
|
|
17
|
+
// search: RouteSearchParamsMap;
|
|
18
|
+
// };
|
|
19
|
+
// }
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// Once augmented, `<Link to="...">` autocompletes the app's routes and type-
|
|
23
|
+
// checks `params`; `useNavigate`, `useParams`, `useSearchParams` follow suit.
|
|
24
|
+
//
|
|
25
|
+
// GRACEFUL FALLBACK
|
|
26
|
+
// -----------------
|
|
27
|
+
// Un-augmented (no codegen, or codegen not yet run), `Register` is `{}`, so the
|
|
28
|
+
// `Register extends { routes: ... } ? ... : <loose>` conditionals below resolve
|
|
29
|
+
// to the loose `string` / `Record<string, string>` types BractJS used before.
|
|
30
|
+
// Existing apps therefore keep compiling unchanged — the typed surface only
|
|
31
|
+
// activates after the generated augmentation lands.
|
|
32
|
+
//
|
|
33
|
+
// NOTE: this seam is mirrored verbatim in `types/index.d.ts` (the published
|
|
34
|
+
// declaration surface). Keep the two in sync — a divergence silently disables
|
|
35
|
+
// typed routing for either monorepo or published consumers.
|
|
36
|
+
|
|
37
|
+
// ── The augmentable seam ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Augmentable registration interface. Empty by default; `route-types.gen.ts`
|
|
41
|
+
* augments it with this app's `RouteRegistry`. See file header.
|
|
42
|
+
*/
|
|
43
|
+
export interface Register {}
|
|
44
|
+
|
|
45
|
+
/** The shape the generated file plugs into `Register["routes"]`. */
|
|
46
|
+
export interface RouteRegistry {
|
|
47
|
+
/** Union of all route patterns, colon-style — e.g. `"/" | "/blog/:id"`. */
|
|
48
|
+
routes: string;
|
|
49
|
+
/** Map of pattern → params object — e.g. `{ "/blog/:id": { id: string } }`. */
|
|
50
|
+
params: Record<string, Record<string, string>>;
|
|
51
|
+
/** Map of pattern → search-params object. */
|
|
52
|
+
search: Record<string, Record<string, string>>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Package-level customization maps (stable augmentation targets) ───────────
|
|
56
|
+
//
|
|
57
|
+
// Users type search params / context per route by augmenting THESE interfaces
|
|
58
|
+
// on the package, e.g.:
|
|
59
|
+
//
|
|
60
|
+
// declare module "@bractjs/bractjs" {
|
|
61
|
+
// interface RouteSearchParamsMap { "/posts": { page: string } }
|
|
62
|
+
// }
|
|
63
|
+
//
|
|
64
|
+
// The generated file seeds every route with a permissive default; user
|
|
65
|
+
// augmentations merge on top.
|
|
66
|
+
|
|
67
|
+
/** Per-route search-params shapes. Augment to type a route's search params. */
|
|
68
|
+
export interface RouteSearchParamsMap {}
|
|
69
|
+
|
|
70
|
+
/** Per-route context shapes. Augment to type a route's `context`. */
|
|
71
|
+
export interface RouteContextMap {}
|
|
72
|
+
|
|
73
|
+
// ── Resolution helpers (drive the runtime hooks/components) ──────────────────
|
|
74
|
+
|
|
75
|
+
// NOTE: these conditionals deliberately do NOT use `infer R extends RouteRegistry`.
|
|
76
|
+
// A constrained `infer` here silently fails to match the generated registry (its
|
|
77
|
+
// `search` member is an empty interface, which trips the constraint check) and
|
|
78
|
+
// falls back to the loose branch — defeating the whole feature. We instead infer
|
|
79
|
+
// each member's shape directly. `RouteRegistry` remains the documented contract
|
|
80
|
+
// the generated file targets.
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The app's route union when registered, else `string`. The fallback is what
|
|
84
|
+
* keeps un-codegen'd apps compiling: every `to` still accepts any string.
|
|
85
|
+
*/
|
|
86
|
+
export type RegisteredRoutes =
|
|
87
|
+
Register extends { routes: { routes: infer R } } ? R : string;
|
|
88
|
+
|
|
89
|
+
/** Pattern → params map when registered, else a permissive map. */
|
|
90
|
+
export type RegisteredParamsMap =
|
|
91
|
+
Register extends { routes: { params: infer P } } ? P : Record<string, Record<string, string>>;
|
|
92
|
+
|
|
93
|
+
/** Pattern → search map when registered, else a permissive map. */
|
|
94
|
+
export type RegisteredSearchMap =
|
|
95
|
+
Register extends { routes: { search: infer S } } ? S : Record<string, Record<string, string>>;
|
|
96
|
+
|
|
97
|
+
/** Params object for a specific route literal (`{}` for static routes). */
|
|
98
|
+
export type ParamsFor<TTo> =
|
|
99
|
+
TTo extends keyof RegisteredParamsMap ? RegisteredParamsMap[TTo] : Record<string, string>;
|
|
100
|
+
|
|
101
|
+
/** Search-params object for a specific route literal. */
|
|
102
|
+
export type SearchFor<TTo> =
|
|
103
|
+
TTo extends keyof RegisteredSearchMap ? RegisteredSearchMap[TTo] : Record<string, string>;
|
|
104
|
+
|
|
105
|
+
/** Whether a route literal carries any path params. Reserved for a future strict `<Link>` mode. */
|
|
106
|
+
export type HasParams<TTo> =
|
|
107
|
+
keyof ParamsFor<TTo> extends never ? false : true;
|
|
@@ -78,29 +78,32 @@ const HEADER =
|
|
|
78
78
|
"\n" +
|
|
79
79
|
"/* eslint-disable */\n";
|
|
80
80
|
|
|
81
|
+
// `RouteSearchParamsMap` / `RouteContextMap` are owned by the package
|
|
82
|
+
// (`@bractjs/bractjs`) so users have a single stable module to augment. We do
|
|
83
|
+
// NOT seed per-route keys here: seeding `"/posts": Record<string,string>` would
|
|
84
|
+
// conflict with a user augmentation declaring `"/posts": { page: string }`
|
|
85
|
+
// (duplicate-property error). Instead `SearchParams<T>` / `Context<T>` fall back
|
|
86
|
+
// to the permissive default for any route the user hasn't augmented.
|
|
81
87
|
function searchParamsTypeLines(routes: Array<{ pattern: string }>): string {
|
|
82
|
-
// Route files may declare `export type SearchParams = { page: string }`.
|
|
83
|
-
// We emit a mapped type that falls back to Record<string,string> per route.
|
|
84
|
-
// Users augment via module augmentation or via their route file's export.
|
|
85
88
|
if (routes.length === 0) {
|
|
86
89
|
return "export type SearchParams<_T extends AppRoutes> = Record<string, string>;";
|
|
87
90
|
}
|
|
88
91
|
const branches = routes
|
|
89
92
|
.map((r) => {
|
|
90
93
|
assertSafePattern(r.pattern);
|
|
91
|
-
|
|
94
|
+
const key = JSON.stringify(r.pattern);
|
|
95
|
+
// Use `extends Record<K, infer V>` rather than `keyof` + index access:
|
|
96
|
+
// RouteSearchParamsMap may be `{}` (no user augmentation), and indexing an
|
|
97
|
+
// empty interface — even inside a `K extends keyof M` guard — trips
|
|
98
|
+
// TS2538 "cannot be used as an index type". The Record-infer form resolves
|
|
99
|
+
// V only when the route is augmented, and falls back otherwise.
|
|
100
|
+
return " T extends " + key +
|
|
101
|
+
" ? (RouteSearchParamsMap extends Record<" + key + ", infer V> ? V : Record<string, string>) :";
|
|
92
102
|
})
|
|
93
103
|
.join("\n");
|
|
94
|
-
const mapEntries = routes
|
|
95
|
-
.map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, string>;")
|
|
96
|
-
.join("\n");
|
|
97
104
|
return [
|
|
98
|
-
"// Augment RouteSearchParamsMap to type search params
|
|
99
|
-
"//
|
|
100
|
-
"export interface RouteSearchParamsMap {",
|
|
101
|
-
mapEntries,
|
|
102
|
-
"}",
|
|
103
|
-
"",
|
|
105
|
+
"// Augment RouteSearchParamsMap (on the package) to type a route's search params:",
|
|
106
|
+
"// declare module \"@bractjs/bractjs\" { interface RouteSearchParamsMap { \"/blog\": { page: string } } }",
|
|
104
107
|
"export type SearchParams<T extends AppRoutes> =",
|
|
105
108
|
branches,
|
|
106
109
|
" Record<string, string>;",
|
|
@@ -114,25 +117,51 @@ function contextTypeLines(routes: Array<{ pattern: string }>): string {
|
|
|
114
117
|
const branches = routes
|
|
115
118
|
.map((r) => {
|
|
116
119
|
assertSafePattern(r.pattern);
|
|
117
|
-
|
|
120
|
+
const key = JSON.stringify(r.pattern);
|
|
121
|
+
// See SearchParams above for why this uses `extends Record<K, infer V>`.
|
|
122
|
+
return " T extends " + key +
|
|
123
|
+
" ? (RouteContextMap extends Record<" + key + ", infer V> ? V : Record<string, unknown>) :";
|
|
118
124
|
})
|
|
119
125
|
.join("\n");
|
|
120
|
-
const mapEntries = routes
|
|
121
|
-
.map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, unknown>;")
|
|
122
|
-
.join("\n");
|
|
123
126
|
return [
|
|
124
|
-
"// Augment RouteContextMap to type
|
|
125
|
-
"//
|
|
126
|
-
"export interface RouteContextMap {",
|
|
127
|
-
mapEntries,
|
|
128
|
-
"}",
|
|
129
|
-
"",
|
|
127
|
+
"// Augment RouteContextMap (on the package) to type a route's context:",
|
|
128
|
+
"// declare module \"@bractjs/bractjs\" { interface RouteContextMap { \"/blog\": { user: User } } }",
|
|
130
129
|
"export type Context<T extends AppRoutes> =",
|
|
131
130
|
branches,
|
|
132
131
|
" Record<string, unknown>;",
|
|
133
132
|
].join("\n");
|
|
134
133
|
}
|
|
135
134
|
|
|
135
|
+
// The `Register` augmentation: this is what wires the app's routes into the
|
|
136
|
+
// package's runtime helpers (<Link>, useNavigate, useParams, useSearchParams).
|
|
137
|
+
function registerAugmentationLines(routes: Array<{ pattern: string; params: string[] }>): string {
|
|
138
|
+
const paramEntries = routes
|
|
139
|
+
.map((r) => {
|
|
140
|
+
assertSafePattern(r.pattern);
|
|
141
|
+
r.params.forEach(assertSafeParam);
|
|
142
|
+
const shape = r.params.length === 0
|
|
143
|
+
? "{}"
|
|
144
|
+
: "{ " + r.params.map((p) => p + ": string").join("; ") + " }";
|
|
145
|
+
return " " + JSON.stringify(r.pattern) + ": " + shape + ";";
|
|
146
|
+
})
|
|
147
|
+
.join("\n");
|
|
148
|
+
return [
|
|
149
|
+
"// Registers this app's routes with BractJS. After this augmentation, <Link>,",
|
|
150
|
+
"// useNavigate, useParams, and useSearchParams are type-safe against AppRoutes.",
|
|
151
|
+
"declare module \"@bractjs/bractjs\" {",
|
|
152
|
+
" interface Register {",
|
|
153
|
+
" routes: {",
|
|
154
|
+
" routes: AppRoutes;",
|
|
155
|
+
" params: {",
|
|
156
|
+
paramEntries,
|
|
157
|
+
" };",
|
|
158
|
+
" search: RouteSearchParamsMap;",
|
|
159
|
+
" };",
|
|
160
|
+
" }",
|
|
161
|
+
"}",
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
136
165
|
export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
137
166
|
const routeFiles = await scanRoutes(appDir);
|
|
138
167
|
const routes = routeFiles.map((r) => ({
|
|
@@ -149,8 +178,15 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
149
178
|
|
|
150
179
|
const builderEntries = routes.map((r) => builderEntry(r.pattern, r.params)).join("\n");
|
|
151
180
|
|
|
181
|
+
// `RouteSearchParamsMap` / `RouteContextMap` are imported from the package so
|
|
182
|
+
// the local `SearchParams<T>` / `Context<T>` reference the same interfaces the
|
|
183
|
+
// user augments via `declare module "@bractjs/bractjs"`.
|
|
184
|
+
const IMPORTS = 'import type { RouteSearchParamsMap, RouteContextMap } from "@bractjs/bractjs";';
|
|
185
|
+
|
|
152
186
|
return [
|
|
153
187
|
HEADER,
|
|
188
|
+
IMPORTS,
|
|
189
|
+
"",
|
|
154
190
|
"export type AppRoutes =",
|
|
155
191
|
union + ";",
|
|
156
192
|
"",
|
|
@@ -175,6 +211,9 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
175
211
|
builderEntries,
|
|
176
212
|
"} as const;",
|
|
177
213
|
"",
|
|
214
|
+
// No routes → AppRoutes is `never`; nothing to register.
|
|
215
|
+
routes.length > 0 ? registerAugmentationLines(routes) : "",
|
|
216
|
+
"",
|
|
178
217
|
].join("\n");
|
|
179
218
|
}
|
|
180
219
|
|
package/src/index.ts
CHANGED
|
@@ -108,6 +108,8 @@ export { useLoaderData } from "./client/hooks/useLoaderData.ts";
|
|
|
108
108
|
export { useActionData } from "./client/hooks/useActionData.ts";
|
|
109
109
|
export { useParams } from "./client/hooks/useParams.ts";
|
|
110
110
|
export { useNavigation } from "./client/hooks/useNavigation.ts";
|
|
111
|
+
export { useNavigate } from "./client/hooks/useNavigate.ts";
|
|
112
|
+
export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
|
|
111
113
|
export { useFetcher } from "./client/hooks/useFetcher.ts";
|
|
112
114
|
export { useSearchParams } from "./client/hooks/useSearchParams.ts";
|
|
113
115
|
export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
|
|
@@ -115,6 +117,21 @@ export { useBlocker } from "./client/hooks/useBlocker.ts";
|
|
|
115
117
|
export { useLocale } from "./client/hooks/useLocale.ts";
|
|
116
118
|
export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
|
|
117
119
|
|
|
120
|
+
// Typed-routing registration seam. Augment `Register` (done by `bractjs codegen`
|
|
121
|
+
// in app/route-types.gen.ts) to make <Link>, useNavigate, useParams, and
|
|
122
|
+
// useSearchParams type-safe. Augment RouteSearchParamsMap / RouteContextMap to
|
|
123
|
+
// type a route's search params / context.
|
|
124
|
+
export type {
|
|
125
|
+
Register,
|
|
126
|
+
RouteRegistry,
|
|
127
|
+
RegisteredRoutes,
|
|
128
|
+
ParamsFor,
|
|
129
|
+
SearchFor,
|
|
130
|
+
RouteSearchParamsMap,
|
|
131
|
+
RouteContextMap,
|
|
132
|
+
} from "./client/registry.ts";
|
|
133
|
+
export { buildPath } from "./client/build-path.ts";
|
|
134
|
+
|
|
118
135
|
// i18n utilities (server-side)
|
|
119
136
|
export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
|
|
120
137
|
export type { I18nConfig } from "./server/serve.ts";
|
package/types/index.d.ts
CHANGED
|
@@ -129,13 +129,49 @@ export declare function validate<T>(
|
|
|
129
129
|
input: FormData | Record<string, unknown>,
|
|
130
130
|
): Promise<T>;
|
|
131
131
|
|
|
132
|
+
// ── Typed-routing registration seam ───────────────────────────────────────
|
|
133
|
+
// Mirror of src/client/registry.ts. Augment `Register` (done by `bractjs codegen`
|
|
134
|
+
// in app/route-types.gen.ts) to make <Link>/useNavigate/useParams/useSearchParams
|
|
135
|
+
// type-safe. Un-augmented, everything falls back to loose `string` / Record so
|
|
136
|
+
// apps that never run codegen keep compiling. Keep in sync with registry.ts.
|
|
137
|
+
export interface Register {}
|
|
138
|
+
export interface RouteRegistry {
|
|
139
|
+
routes: string;
|
|
140
|
+
params: Record<string, Record<string, string>>;
|
|
141
|
+
search: Record<string, Record<string, string>>;
|
|
142
|
+
}
|
|
143
|
+
export interface RouteSearchParamsMap {}
|
|
144
|
+
export interface RouteContextMap {}
|
|
145
|
+
// Infer each member directly (NOT `infer R extends RouteRegistry` — a constrained
|
|
146
|
+
// infer fails to match the generated registry and falls back to loose). Keep in
|
|
147
|
+
// sync with src/client/registry.ts.
|
|
148
|
+
export type RegisteredRoutes =
|
|
149
|
+
Register extends { routes: { routes: infer R } } ? R : string;
|
|
150
|
+
export type RegisteredParamsMap =
|
|
151
|
+
Register extends { routes: { params: infer P } } ? P : Record<string, Record<string, string>>;
|
|
152
|
+
export type RegisteredSearchMap =
|
|
153
|
+
Register extends { routes: { search: infer S } } ? S : Record<string, Record<string, string>>;
|
|
154
|
+
export type ParamsFor<TTo> =
|
|
155
|
+
TTo extends keyof RegisteredParamsMap ? RegisteredParamsMap[TTo] : Record<string, string>;
|
|
156
|
+
export type SearchFor<TTo> =
|
|
157
|
+
TTo extends keyof RegisteredSearchMap ? RegisteredSearchMap[TTo] : Record<string, string>;
|
|
158
|
+
export declare function buildPath(pattern: string, params: Record<string, string | number>): string;
|
|
159
|
+
|
|
132
160
|
// ── Client components ─────────────────────────────────────────────────────
|
|
133
161
|
export declare function Scripts(): null;
|
|
134
162
|
export declare function LiveReload(): ReactNode;
|
|
135
163
|
export declare function Outlet(): ReactNode;
|
|
136
164
|
|
|
137
|
-
export
|
|
138
|
-
|
|
165
|
+
export type LinkProps<TTo extends RegisteredRoutes = RegisteredRoutes> = {
|
|
166
|
+
to: TTo | (string & {});
|
|
167
|
+
params?: ParamsFor<TTo>;
|
|
168
|
+
prefetch?: "hover" | "none";
|
|
169
|
+
viewTransition?: boolean;
|
|
170
|
+
children?: ReactNode;
|
|
171
|
+
className?: string;
|
|
172
|
+
[key: string]: unknown;
|
|
173
|
+
};
|
|
174
|
+
export declare function Link<TTo extends RegisteredRoutes = RegisteredRoutes>(props: LinkProps<TTo>): ReactNode;
|
|
139
175
|
|
|
140
176
|
export interface FormProps { method?: "post" | "put" | "delete"; action?: string; children?: ReactNode; [key: string]: unknown; }
|
|
141
177
|
export declare function Form(props: FormProps): ReactNode;
|
|
@@ -163,9 +199,16 @@ export declare function Image(props: ImageProps): ReactNode;
|
|
|
163
199
|
// ── Client hooks ──────────────────────────────────────────────────────────
|
|
164
200
|
export declare function useLoaderData<T = unknown>(): T;
|
|
165
201
|
export declare function useActionData<T = unknown>(): T | null;
|
|
166
|
-
export declare function useParams():
|
|
202
|
+
export declare function useParams<TTo extends string>(): ParamsFor<TTo>;
|
|
203
|
+
export declare function useParams<T extends Record<string, string> = Record<string, string>>(): T;
|
|
167
204
|
export type NavigationState = "idle" | "loading" | "submitting";
|
|
168
205
|
export declare function useNavigation(): { state: NavigationState };
|
|
206
|
+
|
|
207
|
+
export interface NavigateOptions<TTo extends RegisteredRoutes = RegisteredRoutes> { params?: ParamsFor<TTo>; }
|
|
208
|
+
export interface NavigateFn {
|
|
209
|
+
<TTo extends RegisteredRoutes>(to: TTo | (string & {}), options?: NavigateOptions<TTo>): Promise<void>;
|
|
210
|
+
}
|
|
211
|
+
export declare function useNavigate(): NavigateFn;
|
|
169
212
|
export interface FetcherResult {
|
|
170
213
|
data: unknown;
|
|
171
214
|
state: NavigationState;
|
|
@@ -184,6 +227,7 @@ export interface SearchParamsResult<T extends Record<string, string> = Record<st
|
|
|
184
227
|
getParam<K extends keyof T & string>(key: K): T[K] | null;
|
|
185
228
|
setSearchParams(updater: Record<string, string> | ((prev: URLSearchParams) => URLSearchParams)): void;
|
|
186
229
|
}
|
|
230
|
+
export declare function useSearchParams<TTo extends string>(): SearchParamsResult<SearchFor<TTo>>;
|
|
187
231
|
export declare function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
|
|
188
232
|
|
|
189
233
|
// ── Typed route context ───────────────────────────────────────────────────
|