@bractjs/bractjs 0.1.25 → 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 +773 -465
- package/bin/cli.ts +23 -3
- 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__/compile-safety.test.ts +163 -0
- package/src/__tests__/compile-smoke.test.ts +276 -0
- package/src/__tests__/csp.test.ts +80 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
- package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
- package/src/__tests__/integration.test.ts +62 -0
- package/src/__tests__/layout-registry.test.ts +23 -0
- package/src/__tests__/loader.test.ts +23 -0
- package/src/__tests__/middleware.test.ts +22 -0
- package/src/__tests__/programmatic-api.test.ts +41 -2
- package/src/__tests__/response.test.ts +54 -1
- package/src/__tests__/security.test.ts +35 -0
- package/src/__tests__/server-module-stub.test.ts +145 -0
- package/src/__tests__/stream-handler.test.ts +36 -0
- package/src/__tests__/typed-routing.test.ts +189 -0
- package/src/build/bundler.ts +46 -20
- package/src/build/directives.ts +2 -2
- package/src/build/env-plugin.ts +63 -0
- package/src/build/react-dedupe.ts +41 -0
- package/src/client/ClientRouter.tsx +22 -8
- package/src/client/build-path.ts +24 -0
- package/src/client/components/Form.tsx +10 -1
- package/src/client/components/Link.tsx +31 -8
- package/src/client/hooks/useFetcher.ts +17 -1
- 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/nav-utils.ts +54 -3
- package/src/client/registry.ts +107 -0
- package/src/client/types.ts +3 -0
- package/src/codegen/route-codegen.ts +62 -23
- package/src/config/load.ts +50 -2
- package/src/dev/devtools.ts +72 -39
- package/src/dev/hmr-module-handler.ts +6 -4
- package/src/dev/rebuilder.ts +16 -1
- package/src/dev/server.ts +3 -0
- package/src/index.ts +30 -3
- package/src/server/csp.ts +92 -0
- package/src/server/csrf.ts +44 -6
- package/src/server/layout.ts +12 -2
- package/src/server/loader.ts +5 -7
- package/src/server/render.ts +29 -10
- package/src/server/request-handler.ts +15 -4
- package/src/server/response.ts +58 -5
- package/src/server/serve.ts +10 -0
- package/src/server/static.ts +11 -1
- package/src/server/stream-handler.ts +8 -7
- package/src/server/use-client-runtime.ts +62 -0
- package/src/shared/meta-tags.tsx +46 -0
- package/types/index.d.ts +67 -5
package/src/server/response.ts
CHANGED
|
@@ -3,12 +3,32 @@ export interface RedirectOptions {
|
|
|
3
3
|
allowExternal?: boolean;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
// Brand stamped onto redirect Responses the app explicitly opted into sending
|
|
7
|
+
// off-origin via `redirect(url, …, { allowExternal: true })`. sanitizeRedirect()
|
|
8
|
+
// (below) lets branded Responses through untouched but neutralizes any *other*
|
|
9
|
+
// 3xx whose Location escapes the request origin — e.g. a loader/action that
|
|
10
|
+
// throws a raw `new Response(null,{status:302,headers:{Location:"//evil"}})`,
|
|
11
|
+
// which would otherwise bypass this helper's guard entirely.
|
|
12
|
+
const ALLOW_EXTERNAL = Symbol.for("bractjs.redirect.allowExternal");
|
|
13
|
+
|
|
14
|
+
export function isSafeInternalRedirect(url: string): boolean {
|
|
15
|
+
// Must be path-only: a single leading "/" that does not begin an authority
|
|
16
|
+
// ("//host", "/\\host") or a scheme. Rejects, raw OR percent-encoded:
|
|
17
|
+
// "//evil.com", "/\\evil.com", "/%2f%2fevil.com", "/%5cevil.com",
|
|
18
|
+
// "https://…", "javascript:…", "" — plus any control/whitespace char that
|
|
19
|
+
// browsers strip or normalize ("/\t//evil", "/\n/evil") before resolving.
|
|
9
20
|
if (url.length === 0) return false;
|
|
21
|
+
// Reject any C0 control char, space, or DEL — browsers strip/normalize these
|
|
22
|
+
// and can turn "/\t//evil" into a protocol-relative escape. Checked via char
|
|
23
|
+
// code (not a regex with control-char literals) to keep the source portable.
|
|
24
|
+
for (let i = 0; i < url.length; i++) {
|
|
25
|
+
const c = url.charCodeAt(i);
|
|
26
|
+
if (c <= 0x20 || c === 0x7f) return false;
|
|
27
|
+
}
|
|
10
28
|
if (url[0] !== "/") return false;
|
|
11
|
-
|
|
29
|
+
const rest = url.slice(1).toLowerCase();
|
|
30
|
+
if (rest.startsWith("/") || rest.startsWith("\\")) return false;
|
|
31
|
+
if (rest.startsWith("%2f") || rest.startsWith("%5c")) return false;
|
|
12
32
|
return true;
|
|
13
33
|
}
|
|
14
34
|
|
|
@@ -26,7 +46,40 @@ export function redirect(
|
|
|
26
46
|
}
|
|
27
47
|
const h = new Headers(headers);
|
|
28
48
|
h.set("Location", url);
|
|
29
|
-
|
|
49
|
+
const res = new Response(null, { status, headers: h });
|
|
50
|
+
// Brand opt-in external redirects so the global sanitizer trusts them.
|
|
51
|
+
if (options?.allowExternal) (res as { [ALLOW_EXTERNAL]?: true })[ALLOW_EXTERNAL] = true;
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Last-line guard applied to every redirect Response the request handler is
|
|
57
|
+
* about to emit. Returns the Response untouched unless it is a 3xx whose
|
|
58
|
+
* `Location` escapes `requestUrl`'s origin AND it was not produced by
|
|
59
|
+
* `redirect(..., { allowExternal: true })`. In that case the off-origin
|
|
60
|
+
* Location is treated as an open-redirect attempt: it is logged and replaced
|
|
61
|
+
* with a 500 so the client never follows it.
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeRedirect(res: Response, requestUrl: string): Response {
|
|
64
|
+
if (res.status < 300 || res.status >= 400) return res;
|
|
65
|
+
if ((res as { [ALLOW_EXTERNAL]?: true })[ALLOW_EXTERNAL]) return res;
|
|
66
|
+
const loc = res.headers.get("Location");
|
|
67
|
+
if (loc === null) return res;
|
|
68
|
+
// Same-origin absolute Locations are fine; reduce to a path and re-check.
|
|
69
|
+
let safe = isSafeInternalRedirect(loc);
|
|
70
|
+
if (!safe) {
|
|
71
|
+
try {
|
|
72
|
+
safe = new URL(loc, requestUrl).origin === new URL(requestUrl).origin;
|
|
73
|
+
} catch {
|
|
74
|
+
safe = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (safe) return res;
|
|
78
|
+
console.error(
|
|
79
|
+
`[bractjs] blocked off-origin redirect Location "${loc}". ` +
|
|
80
|
+
`Use redirect(url, status, headers, { allowExternal: true }) to opt in.`,
|
|
81
|
+
);
|
|
82
|
+
return error("Internal Server Error", 500);
|
|
30
83
|
}
|
|
31
84
|
|
|
32
85
|
export function json<T>(data: T, init?: ResponseInit): Response {
|
package/src/server/serve.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { BunAdapter, type BractAdapter } from "./adapter.ts";
|
|
|
12
12
|
import type { ModuleRegistry } from "./layout.ts";
|
|
13
13
|
import { resolve, join } from "node:path";
|
|
14
14
|
import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
|
|
15
|
+
import { installUseClientServerStub } from "./use-client-runtime.ts";
|
|
15
16
|
|
|
16
17
|
export interface I18nConfig {
|
|
17
18
|
locales: string[];
|
|
@@ -107,6 +108,15 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
107
108
|
}))
|
|
108
109
|
: Promise.resolve(config.manifest ?? DEFAULT_MANIFEST);
|
|
109
110
|
|
|
111
|
+
// When routes are imported from SOURCE at runtime (dev server AND
|
|
112
|
+
// `bractjs start`, which fall back to scanRoutes + dynamic import rather than
|
|
113
|
+
// a pre-stubbed compiled bundle), a `"use client"` route component would
|
|
114
|
+
// execute during SSR and crash on browser-only hooks. Install the runtime
|
|
115
|
+
// stub that null-renders such modules on the server — parity with the
|
|
116
|
+
// compiled bundle's useClientStubPlugin. Skipped on the compiled path, which
|
|
117
|
+
// supplies a pre-built moduleRegistry.
|
|
118
|
+
if (!config.moduleRegistry) installUseClientServerStub(appDir);
|
|
119
|
+
|
|
110
120
|
// Codegen / compiled-binary path: when the caller supplies pre-scanned
|
|
111
121
|
// routes, skip the runtime `Bun.Glob` scan that `bun build --compile`
|
|
112
122
|
// can't satisfy (the routes/ directory isn't on the filesystem in a
|
package/src/server/static.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { join, resolve, sep } from "node:path";
|
|
2
2
|
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { isDevRuntime } from "./env.ts";
|
|
3
4
|
|
|
4
5
|
const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
5
6
|
const NO_CACHE = "no-cache";
|
|
6
7
|
|
|
8
|
+
// Hashed client chunks are safe to cache forever in production (a content change
|
|
9
|
+
// yields a new filename). In DEV, however, the dev rebuilder can reuse a chunk
|
|
10
|
+
// filename across rebuilds while its contents change, so marking them immutable
|
|
11
|
+
// makes the browser pin stale JS (e.g. an old route matcher) for a year. Serve
|
|
12
|
+
// them no-cache in dev so each rebuild is picked up.
|
|
13
|
+
function clientAssetCacheControl(): string {
|
|
14
|
+
return isDevRuntime() ? NO_CACHE : IMMUTABLE;
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
/**
|
|
8
18
|
* Resolve to a canonical path that follows symlinks, with a fallback for
|
|
9
19
|
* `bun build --compile` binaries.
|
|
@@ -66,7 +76,7 @@ export async function serveStatic(
|
|
|
66
76
|
if (!full) return null;
|
|
67
77
|
const file = Bun.file(full);
|
|
68
78
|
if (!(await file.exists())) return null;
|
|
69
|
-
return new Response(file, { headers: { "Cache-Control":
|
|
79
|
+
return new Response(file, { headers: { "Cache-Control": clientAssetCacheControl() } });
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
if (pathname.startsWith("/public/")) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolveAction } from "./action-registry.ts";
|
|
2
2
|
import { isExplicitDev } from "./env.ts";
|
|
3
|
-
import { isAllowedMutation } from "./csrf.ts";
|
|
4
3
|
|
|
5
4
|
// ── SSE helpers ────────────────────────────────────────────────────────────
|
|
6
5
|
|
|
@@ -24,12 +23,14 @@ export async function handleStreamRequest(request: Request): Promise<Response |
|
|
|
24
23
|
// SECURITY(medium): exact-match prevents URL confusion.
|
|
25
24
|
if (url.pathname !== "/_stream") return null;
|
|
26
25
|
|
|
27
|
-
// SECURITY(high): server actions
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
26
|
+
// SECURITY(high): /_stream invokes side-effecting server actions over GET.
|
|
27
|
+
// GET can't carry a body and browsers issue cross-origin GETs from
|
|
28
|
+
// <script>/<img>/<link rel=prefetch> *without* an Origin header, so a bare
|
|
29
|
+
// same-origin-Origin gate is not enough here. Require the client-issued
|
|
30
|
+
// X-BractJS-Action header outright: it's a custom header, so browsers block
|
|
31
|
+
// it cross-origin without a CORS preflight, and the real client (useFetcher)
|
|
32
|
+
// always sends it. This is strictly tighter than the /_action gate.
|
|
33
|
+
if (!request.headers.get("X-BractJS-Action")) {
|
|
33
34
|
return new Response(sseChunk("error", { message: "Forbidden" }), {
|
|
34
35
|
status: 403,
|
|
35
36
|
headers: {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { hasClientDirective, extractExports } from "../build/directives.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runtime stubbing of `"use client"` modules during SSR, for the source-import
|
|
6
|
+
* code path.
|
|
7
|
+
*
|
|
8
|
+
* The compiled server bundle (`bun build --compile`) applies
|
|
9
|
+
* `useClientStubPlugin`, replacing every `"use client"` export with a
|
|
10
|
+
* `() => null` component so SSR never calls browser-only hooks/APIs. But both
|
|
11
|
+
* the dev server AND `bractjs start` render route modules via a raw `import()`
|
|
12
|
+
* of the SOURCE (they fall back to `scanRoutes` + dynamic import rather than the
|
|
13
|
+
* pre-stubbed bundle). Without this, a `"use client"` route component executes
|
|
14
|
+
* on the server and crashes on `useState`/`useRef` ("Invalid hook call").
|
|
15
|
+
*
|
|
16
|
+
* Registering this `Bun.plugin` makes the server process apply the same
|
|
17
|
+
* transform at module-load time. It only affects this process's `import()`
|
|
18
|
+
* (SSR) — the separately bundled client still ships the real component, so
|
|
19
|
+
* hydration restores interactivity in the browser.
|
|
20
|
+
*
|
|
21
|
+
* The filter is scoped to source files UNDER appDir (never node_modules): a
|
|
22
|
+
* runtime onLoad must always return an object, so any matched non-client file is
|
|
23
|
+
* passed through verbatim, and we must not re-transpile third-party packages
|
|
24
|
+
* (doing so breaks CJS interop shapes such as react/jsx-dev-runtime).
|
|
25
|
+
*
|
|
26
|
+
* Idempotent: safe to call more than once per process.
|
|
27
|
+
*/
|
|
28
|
+
let installed = false;
|
|
29
|
+
|
|
30
|
+
function escapeRegExp(s: string): string {
|
|
31
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function installUseClientServerStub(appDir = "./app"): void {
|
|
35
|
+
if (installed) return;
|
|
36
|
+
installed = true;
|
|
37
|
+
|
|
38
|
+
const absAppDir = resolve(appDir);
|
|
39
|
+
const filter = new RegExp(`^${escapeRegExp(absAppDir)}.*\\.(tsx?|jsx?)$`);
|
|
40
|
+
|
|
41
|
+
Bun.plugin({
|
|
42
|
+
name: "bractjs:use-client-server-stub",
|
|
43
|
+
setup(build) {
|
|
44
|
+
build.onLoad({ filter }, async ({ path }) => {
|
|
45
|
+
const src = await Bun.file(path).text();
|
|
46
|
+
const loader = path.endsWith(".tsx") ? "tsx" : path.endsWith(".jsx") ? "jsx" : path.endsWith(".ts") ? "ts" : "js";
|
|
47
|
+
if (!hasClientDirective(src)) {
|
|
48
|
+
// Runtime onLoad must return an object; pass app source through. Bun
|
|
49
|
+
// transpiles app TS/TSX anyway, so this is a no-op in practice.
|
|
50
|
+
return { contents: src, loader };
|
|
51
|
+
}
|
|
52
|
+
const names = extractExports(src).filter((n) => n !== "default");
|
|
53
|
+
const stubs = names.map((n) => `export const ${n} = () => null;`);
|
|
54
|
+
// Always provide a null default — a "use client" module rendered on the
|
|
55
|
+
// server should yield nothing, regardless of how default is declared
|
|
56
|
+
// (which `extractExports` can't always detect, e.g. `export default X`).
|
|
57
|
+
stubs.push("export default () => null;");
|
|
58
|
+
return { contents: stubs.join("\n"), loader: "ts" };
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Fragment, createElement, type ReactElement } from "react";
|
|
2
|
+
import type { MetaDescriptor } from "./route-types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders route `meta()` descriptors as document-metadata elements.
|
|
6
|
+
*
|
|
7
|
+
* React 19 automatically hoists `<title>`, `<meta>`, and `<link>` rendered
|
|
8
|
+
* anywhere in the tree up into `<head>` — both during streaming SSR and on the
|
|
9
|
+
* client. We render the merged descriptors here (inside the SSR shell AND the
|
|
10
|
+
* ClientRouter tree) so:
|
|
11
|
+
* - crawlers and no-JS clients see real <title>/<meta> tags in the SSR HTML,
|
|
12
|
+
* - hydration matches the server tree (no mismatch warning),
|
|
13
|
+
* - soft navigation updates the document head by re-rendering this component.
|
|
14
|
+
*
|
|
15
|
+
* Keys are derived from the descriptor identity (title / name / property) so a
|
|
16
|
+
* later route can override an earlier one without React duplicating the node.
|
|
17
|
+
*/
|
|
18
|
+
export function MetaTags({ meta }: { meta: MetaDescriptor[] }): ReactElement {
|
|
19
|
+
const children: ReactElement[] = [];
|
|
20
|
+
|
|
21
|
+
for (const d of meta) {
|
|
22
|
+
if ("title" in d && typeof (d as { title: unknown }).title === "string") {
|
|
23
|
+
const title = (d as { title: string }).title;
|
|
24
|
+
children.push(createElement("title", { key: "title" }, title));
|
|
25
|
+
} else if ("name" in d && "content" in d) {
|
|
26
|
+
const { name, content } = d as { name: string; content: string };
|
|
27
|
+
children.push(createElement("meta", { key: `name:${name}`, name, content }));
|
|
28
|
+
} else if ("property" in d && "content" in d) {
|
|
29
|
+
const { property, content } = d as { property: string; content: string };
|
|
30
|
+
children.push(createElement("meta", { key: `prop:${property}`, property, content }));
|
|
31
|
+
} else {
|
|
32
|
+
// Arbitrary descriptor: render each string field as a meta attribute set.
|
|
33
|
+
const entries = Object.entries(d).filter(
|
|
34
|
+
([, v]) => typeof v === "string",
|
|
35
|
+
) as Array<[string, string]>;
|
|
36
|
+
if (entries.length > 0) {
|
|
37
|
+
const props: Record<string, string> = {};
|
|
38
|
+
for (const [k, v] of entries) props[k] = v;
|
|
39
|
+
const key = entries.map(([k, v]) => `${k}=${v}`).join("&");
|
|
40
|
+
children.push(createElement("meta", { key: `raw:${key}`, ...props }));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return createElement(Fragment, null, ...children);
|
|
46
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode, Context } from "react";
|
|
1
|
+
import type { ReactNode, Context, CSSProperties } from "react";
|
|
2
2
|
|
|
3
3
|
// ── Route types ───────────────────────────────────────────────────────────
|
|
4
4
|
export type {
|
|
@@ -34,7 +34,8 @@ export interface LifecycleHooks {
|
|
|
34
34
|
export declare function defineLifecycle(hooks: LifecycleHooks): LifecycleHooks;
|
|
35
35
|
export declare function createServer(config?: Partial<BractJSConfig>): { stop(): void };
|
|
36
36
|
export declare function renderRoute(options: RenderOptions): Promise<Response>;
|
|
37
|
-
export
|
|
37
|
+
export interface RedirectOptions { allowExternal?: boolean; }
|
|
38
|
+
export declare function redirect(url: string, status?: number, headers?: HeadersInit, options?: RedirectOptions): Response;
|
|
38
39
|
export declare function json<T>(data: T, init?: ResponseInit): Response;
|
|
39
40
|
export declare function error(message: string, status?: number): Response;
|
|
40
41
|
|
|
@@ -128,13 +129,49 @@ export declare function validate<T>(
|
|
|
128
129
|
input: FormData | Record<string, unknown>,
|
|
129
130
|
): Promise<T>;
|
|
130
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
|
+
|
|
131
160
|
// ── Client components ─────────────────────────────────────────────────────
|
|
132
161
|
export declare function Scripts(): null;
|
|
133
162
|
export declare function LiveReload(): ReactNode;
|
|
134
163
|
export declare function Outlet(): ReactNode;
|
|
135
164
|
|
|
136
|
-
export
|
|
137
|
-
|
|
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;
|
|
138
175
|
|
|
139
176
|
export interface FormProps { method?: "post" | "put" | "delete"; action?: string; children?: ReactNode; [key: string]: unknown; }
|
|
140
177
|
export declare function Form(props: FormProps): ReactNode;
|
|
@@ -142,12 +179,36 @@ export declare function Form(props: FormProps): ReactNode;
|
|
|
142
179
|
export interface AwaitProps<T> { resolve: Promise<T>; fallback: ReactNode; children: (data: T) => ReactNode; }
|
|
143
180
|
export declare function Await<T>(props: AwaitProps<T>): ReactNode;
|
|
144
181
|
|
|
182
|
+
export type ImageFormat = "webp" | "avif" | "jpeg" | "png";
|
|
183
|
+
export type ImageFit = "cover" | "contain" | "fill";
|
|
184
|
+
export interface ImageProps {
|
|
185
|
+
src: string;
|
|
186
|
+
alt: string;
|
|
187
|
+
width?: number;
|
|
188
|
+
height?: number;
|
|
189
|
+
quality?: number;
|
|
190
|
+
format?: ImageFormat;
|
|
191
|
+
fit?: ImageFit;
|
|
192
|
+
priority?: boolean;
|
|
193
|
+
sizes?: string;
|
|
194
|
+
className?: string;
|
|
195
|
+
style?: CSSProperties;
|
|
196
|
+
}
|
|
197
|
+
export declare function Image(props: ImageProps): ReactNode;
|
|
198
|
+
|
|
145
199
|
// ── Client hooks ──────────────────────────────────────────────────────────
|
|
146
200
|
export declare function useLoaderData<T = unknown>(): T;
|
|
147
201
|
export declare function useActionData<T = unknown>(): T | null;
|
|
148
|
-
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;
|
|
149
204
|
export type NavigationState = "idle" | "loading" | "submitting";
|
|
150
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;
|
|
151
212
|
export interface FetcherResult {
|
|
152
213
|
data: unknown;
|
|
153
214
|
state: NavigationState;
|
|
@@ -166,6 +227,7 @@ export interface SearchParamsResult<T extends Record<string, string> = Record<st
|
|
|
166
227
|
getParam<K extends keyof T & string>(key: K): T[K] | null;
|
|
167
228
|
setSearchParams(updater: Record<string, string> | ((prev: URLSearchParams) => URLSearchParams)): void;
|
|
168
229
|
}
|
|
230
|
+
export declare function useSearchParams<TTo extends string>(): SearchParamsResult<SearchFor<TTo>>;
|
|
169
231
|
export declare function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T>;
|
|
170
232
|
|
|
171
233
|
// ── Typed route context ───────────────────────────────────────────────────
|