lakebed 0.0.19 → 0.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -474,6 +474,26 @@ function routeSystemPath(pathname) {
474
474
  return pathname;
475
475
  }
476
476
 
477
+ function wantsHtml(req) {
478
+ const accept = String(req.headers.accept ?? "");
479
+ return !accept || accept.includes("text/html");
480
+ }
481
+
482
+ function isReservedClientShellPath(pathname) {
483
+ return (
484
+ pathname === "/client.js" ||
485
+ pathname === "/__lakebed" ||
486
+ pathname.startsWith("/__lakebed/") ||
487
+ pathname === "/__span" ||
488
+ pathname.startsWith("/__span/") ||
489
+ (pathname.startsWith("/auth/") && pathname !== "/auth/callback")
490
+ );
491
+ }
492
+
493
+ function isClientShellRequest(req, pathname) {
494
+ return req.method === "GET" && wantsHtml(req) && !isReservedClientShellPath(pathname);
495
+ }
496
+
477
497
  function parsePathDeploy(url) {
478
498
  const parts = url.pathname.split("/").filter(Boolean);
479
499
  if (parts[0] !== "d" || !parts[1]) {
@@ -6771,6 +6791,14 @@ export async function startAnonymousServer({
6771
6791
  return;
6772
6792
  }
6773
6793
 
6794
+ if (isClientShellRequest(req, appPath)) {
6795
+ sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath, { clientBundleHash: loaded.deploy.clientBundleHash, shooBaseUrl }), {
6796
+ "Cache-Control": "no-store",
6797
+ "Content-Type": "text/html; charset=utf-8"
6798
+ });
6799
+ return;
6800
+ }
6801
+
6774
6802
  sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
6775
6803
  } catch (error) {
6776
6804
  if (isQuotaError(error)) {
package/src/cli.js CHANGED
@@ -452,6 +452,26 @@ function html(title, { shooBaseUrl } = {}) {
452
452
  </html>`;
453
453
  }
454
454
 
455
+ function wantsHtml(req) {
456
+ const accept = String(req.headers.accept ?? "");
457
+ return !accept || accept.includes("text/html");
458
+ }
459
+
460
+ function isReservedClientShellPath(pathname) {
461
+ return (
462
+ pathname === "/client.js" ||
463
+ pathname === "/__lakebed" ||
464
+ pathname.startsWith("/__lakebed/") ||
465
+ pathname === "/__span" ||
466
+ pathname.startsWith("/__span/") ||
467
+ (pathname.startsWith("/auth/") && pathname !== "/auth/callback")
468
+ );
469
+ }
470
+
471
+ function isClientShellRequest(req, pathname) {
472
+ return req.method === "GET" && wantsHtml(req) && !isReservedClientShellPath(pathname);
473
+ }
474
+
455
475
  function sendJson(ws, message) {
456
476
  ws.send(JSON.stringify(message));
457
477
  }
@@ -819,6 +839,12 @@ export async function startDevServer({
819
839
  return;
820
840
  }
821
841
 
842
+ if (isClientShellRequest(req, requestUrl.pathname)) {
843
+ res.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "text/html; charset=utf-8" });
844
+ res.end(html(currentBuild.app.name ?? "Lakebed Capsule", { shooBaseUrl }));
845
+ return;
846
+ }
847
+
822
848
  res.writeHead(404);
823
849
  res.end("Not found");
824
850
  } catch (error) {
@@ -1751,7 +1777,7 @@ Your role is to build software within this capsule. Lakebed is the runtime, the
1751
1777
  - Data needed on client should be fetched through queries. User-driven changes should be done via mutations. Endpoints should be treated as an "escape hatch" for exposing functionality over endpoints for HTTP-based flows.
1752
1778
  - Styling must be done via raw CSS or Tailwind classes in the JSX.
1753
1779
  - Do not add a CSS, PostCSS, or Tailwind build pipeline. They are built in.
1754
- - There is no file based routing. You can define routes yourself through typescript.
1780
+ - There is no file based routing. Use the built-in client router from \`lakebed/client\` when you need pages.
1755
1781
  - All imports must be from Lakebed or from relative paths.
1756
1782
  - Do not use Node built-ins in app code.
1757
1783
  - Use auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
@@ -1812,7 +1838,7 @@ function todoTemplate(name) {
1812
1838
  return {
1813
1839
  "AGENTS.md": agentInstructions,
1814
1840
  "CLAUDE.md": agentInstructions,
1815
- "server/index.ts": `import { boolean, capsule, mutation, query, string, table } from "lakebed/server";
1841
+ "server/index.ts": `import { boolean, capsule, endpoint, mutation, query, string, table, text } from "lakebed/server";
1816
1842
  import { cleanTodoText } from "../shared/todo";
1817
1843
 
1818
1844
  export default capsule({
@@ -1844,10 +1870,15 @@ export default capsule({
1844
1870
 
1845
1871
  ctx.db.todos.insert({ text: cleanText, ownerId: ctx.auth.userId });
1846
1872
  })
1873
+ },
1874
+
1875
+ endpoints: {
1876
+ status: endpoint({ method: "GET", path: "/api/status" }, () => text("ok"))
1847
1877
  }
1848
1878
  });
1849
1879
  `,
1850
- "client/index.tsx": `import { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
1880
+ "client/index.tsx": `import { Link, Route, Router, Routes, SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
1881
+ import { useState } from "preact/hooks";
1851
1882
  import { cleanTodoText, type Todo } from "../shared/todo";
1852
1883
 
1853
1884
  function AuthAvatar({ label, picture }: { label: string; picture?: string }) {
@@ -1874,12 +1905,9 @@ function AuthAvatar({ label, picture }: { label: string; picture?: string }) {
1874
1905
  );
1875
1906
  }
1876
1907
 
1877
- export function App() {
1878
- const auth = useAuth();
1908
+ function TodoPage() {
1879
1909
  const todos = useQuery<Todo[]>("todos");
1880
1910
  const addTodo = useMutation<[text: string], void>("addTodo");
1881
- const authLabel = auth.displayName;
1882
- const authStatus = auth.isLoading && auth.isGuest ? "checking session" : "signed in as " + authLabel;
1883
1911
 
1884
1912
  async function onSubmit(event: SubmitEvent) {
1885
1913
  event.preventDefault();
@@ -1895,33 +1923,75 @@ export function App() {
1895
1923
  }
1896
1924
 
1897
1925
  return (
1898
- <main className="min-h-screen bg-black px-6 py-10 text-white">
1899
- <section className="mx-auto max-w-2xl">
1900
- <div className="mb-3 flex items-center justify-between gap-3">
1901
- <div className="flex min-w-0 items-center gap-2">
1902
- {!auth.isLoading ? <AuthAvatar label={authLabel} picture={auth.picture} /> : null}
1903
- <p className="min-w-0 truncate font-mono text-sm text-neutral-500">{authStatus}</p>
1926
+ <section>
1927
+ <h1 className="mb-8 text-5xl font-bold tracking-tight">${title}</h1>
1928
+ <form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
1929
+ <input className="min-w-0 flex-1 border border-neutral-700 bg-black px-3 py-2 text-white outline-none focus:border-white" name="text" placeholder="Add a todo" />
1930
+ <button className="border border-white px-4 py-2 font-medium" type="submit">Add</button>
1931
+ </form>
1932
+ <ul className="divide-y divide-neutral-800 border-y border-neutral-800">
1933
+ {todos.map((todo) => (
1934
+ <li className="py-3" key={todo.id}>{todo.text}</li>
1935
+ ))}
1936
+ </ul>
1937
+ </section>
1938
+ );
1939
+ }
1940
+
1941
+ function StatusPage() {
1942
+ const [status, setStatus] = useState("not checked");
1943
+
1944
+ async function checkStatus() {
1945
+ const response = await fetch("api/status");
1946
+ setStatus(response.ok ? await response.text() : "error " + response.status);
1947
+ }
1948
+
1949
+ return (
1950
+ <section>
1951
+ <h1 className="mb-4 text-4xl font-bold tracking-tight">Status</h1>
1952
+ <p className="mb-6 text-neutral-400">This route calls the server endpoint at /api/status.</p>
1953
+ <button className="border border-white px-4 py-2 font-medium" type="button" onClick={() => void checkStatus()}>
1954
+ Check endpoint
1955
+ </button>
1956
+ <p className="mt-4 font-mono text-sm text-neutral-400">endpoint: {status}</p>
1957
+ </section>
1958
+ );
1959
+ }
1960
+
1961
+ export function App() {
1962
+ const auth = useAuth();
1963
+ const authLabel = auth.displayName;
1964
+ const authStatus = auth.isLoading && auth.isGuest ? "checking session" : "signed in as " + authLabel;
1965
+
1966
+ return (
1967
+ <Router>
1968
+ <main className="min-h-screen bg-black px-6 py-10 text-white">
1969
+ <section className="mx-auto max-w-2xl">
1970
+ <div className="mb-3 flex items-center justify-between gap-3">
1971
+ <div className="flex min-w-0 items-center gap-2">
1972
+ {!auth.isLoading ? <AuthAvatar label={authLabel} picture={auth.picture} /> : null}
1973
+ <p className="min-w-0 truncate font-mono text-sm text-neutral-500">{authStatus}</p>
1974
+ </div>
1975
+ {!auth.isLoading && auth.isGuest ? (
1976
+ <SignInWithGoogle className="shrink-0 border border-neutral-700 px-3 py-1.5 text-sm font-medium text-neutral-200 hover:border-white hover:text-white" />
1977
+ ) : !auth.isLoading ? (
1978
+ <button className="shrink-0 text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
1979
+ Sign out
1980
+ </button>
1981
+ ) : null}
1904
1982
  </div>
1905
- {!auth.isLoading && auth.isGuest ? (
1906
- <SignInWithGoogle className="shrink-0 border border-neutral-700 px-3 py-1.5 text-sm font-medium text-neutral-200 hover:border-white hover:text-white" />
1907
- ) : !auth.isLoading ? (
1908
- <button className="shrink-0 text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
1909
- Sign out
1910
- </button>
1911
- ) : null}
1912
- </div>
1913
- <h1 className="mb-8 text-5xl font-bold tracking-tight">${title}</h1>
1914
- <form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
1915
- <input className="min-w-0 flex-1 border border-neutral-700 bg-black px-3 py-2 text-white outline-none focus:border-white" name="text" placeholder="Add a todo" />
1916
- <button className="border border-white px-4 py-2 font-medium" type="submit">Add</button>
1917
- </form>
1918
- <ul className="divide-y divide-neutral-800 border-y border-neutral-800">
1919
- {todos.map((todo) => (
1920
- <li className="py-3" key={todo.id}>{todo.text}</li>
1921
- ))}
1922
- </ul>
1923
- </section>
1924
- </main>
1983
+ <nav className="mb-8 flex gap-4 text-sm text-neutral-400">
1984
+ <Link className="hover:text-white" to="/">Todos</Link>
1985
+ <Link className="hover:text-white" to="/status">Status</Link>
1986
+ </nav>
1987
+ <Routes>
1988
+ <Route path="/" element={<TodoPage />} />
1989
+ <Route path="/status" element={<StatusPage />} />
1990
+ <Route path="*" element={<section><h1 className="mb-4 text-4xl font-bold">Not found</h1><Link className="text-neutral-300 hover:text-white" to="/">Back to todos</Link></section>} />
1991
+ </Routes>
1992
+ </section>
1993
+ </main>
1994
+ </Router>
1925
1995
  );
1926
1996
  }
1927
1997
  `,
@@ -1948,6 +2018,17 @@ Run this Lakebed capsule:
1948
2018
  \`\`\`sh
1949
2019
  npx lakebed dev
1950
2020
  \`\`\`
2021
+
2022
+ The starter app includes two client routes:
2023
+
2024
+ - \`/\`: the todo list.
2025
+ - \`/status\`: a page that calls the \`GET /api/status\` endpoint.
2026
+
2027
+ You can also call the endpoint directly:
2028
+
2029
+ \`\`\`sh
2030
+ curl http://localhost:3000/api/status
2031
+ \`\`\`
1951
2032
  `
1952
2033
  };
1953
2034
  }
package/src/client.d.ts CHANGED
@@ -53,11 +53,49 @@ export type SignInWithGoogleProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElem
53
53
  children?: ComponentChildren;
54
54
  };
55
55
 
56
+ export type LakebedLocation = {
57
+ pathname: string;
58
+ search: string;
59
+ hash: string;
60
+ href: string;
61
+ };
62
+
63
+ export type NavigateOptions = {
64
+ replace?: boolean;
65
+ };
66
+
67
+ export type RouterProps = {
68
+ children?: ComponentChildren;
69
+ };
70
+
71
+ export type RoutesProps = {
72
+ children?: ComponentChildren;
73
+ };
74
+
75
+ export type RouteProps = {
76
+ path: string;
77
+ element: ComponentChildren;
78
+ };
79
+
80
+ export type LinkProps = Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
81
+ to: string;
82
+ replace?: boolean;
83
+ children?: ComponentChildren;
84
+ };
85
+
56
86
  export function useAuth(): Auth;
57
87
  export function signInWithGoogle(options?: SignInWithGoogleOptions): Promise<SignInWithGoogleResult>;
58
88
  export function signOut(): void;
59
89
  export function getIdentity(): Identity;
60
90
  export function decodeIdentityClaims(idToken?: string): IdentityClaims | null;
61
91
  export function SignInWithGoogle(props?: SignInWithGoogleProps): JSX.Element;
92
+ export function Router(props?: RouterProps): JSX.Element;
93
+ export function Routes(props?: RoutesProps): JSX.Element | null;
94
+ export function Route(props: RouteProps): JSX.Element | null;
95
+ export function Link(props: LinkProps): JSX.Element;
96
+ export function navigate(to: string, options?: NavigateOptions): void;
97
+ export function useLocation(): LakebedLocation;
98
+ export function useNavigate(): (to: string, options?: NavigateOptions) => void;
99
+ export function useParams<TParams = Record<string, string | undefined>>(): TParams;
62
100
  export function useQuery<T>(name: string): T;
63
101
  export function useMutation<TArgs extends unknown[], TResult>(name: string): (...args: TArgs) => Promise<TResult>;
package/src/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { h } from "preact";
2
- import { useEffect, useState } from "preact/hooks";
1
+ import { createContext, h, toChildArray } from "preact";
2
+ import { useContext, useEffect, useState } from "preact/hooks";
3
3
 
4
4
  const DEFAULT_SHOO_BASE_URL = "https://shoo.dev";
5
5
  const AUTH_STORAGE_KEY = "lakebed_identity";
@@ -23,6 +23,14 @@ let authInitialized = false;
23
23
  let authResumeStarted = false;
24
24
  let refreshRequested = false;
25
25
 
26
+ const RouterContext = createContext(null);
27
+ const RouteContext = createContext({ params: {} });
28
+
29
+ function normalizeBasePathValue(value) {
30
+ const clean = String(value ?? "").replace(/\/+$/g, "");
31
+ return clean === "/" ? "" : clean;
32
+ }
33
+
26
34
  function toGuestName(name) {
27
35
  return (
28
36
  String(name ?? "local")
@@ -144,7 +152,7 @@ function send(message) {
144
152
  }
145
153
 
146
154
  function basePath() {
147
- return window.__LAKEBED_BASE_PATH__ ?? "";
155
+ return normalizeBasePathValue(window.__LAKEBED_BASE_PATH__ ?? "");
148
156
  }
149
157
 
150
158
  function authConfig() {
@@ -159,6 +167,214 @@ function currentRoute() {
159
167
  return `${window.location.pathname}${window.location.search}${window.location.hash}`;
160
168
  }
161
169
 
170
+ function appPathnameFromBrowserPathname(pathname) {
171
+ const base = basePath();
172
+ if (!base) {
173
+ return pathname || "/";
174
+ }
175
+
176
+ if (pathname === base) {
177
+ return "/";
178
+ }
179
+
180
+ if (pathname.startsWith(`${base}/`)) {
181
+ return pathname.slice(base.length) || "/";
182
+ }
183
+
184
+ return pathname || "/";
185
+ }
186
+
187
+ function currentAppLocation() {
188
+ if (typeof window === "undefined") {
189
+ return { hash: "", href: "/", pathname: "/", search: "" };
190
+ }
191
+
192
+ const pathname = appPathnameFromBrowserPathname(window.location.pathname);
193
+ const search = window.location.search;
194
+ const hash = window.location.hash;
195
+ return {
196
+ hash,
197
+ href: `${pathname}${search}${hash}`,
198
+ pathname,
199
+ search
200
+ };
201
+ }
202
+
203
+ function isExternalHref(value) {
204
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) || value.startsWith("//");
205
+ }
206
+
207
+ function browserHrefForAppHref(appHref) {
208
+ const url = new URL(appHref, "http://lakebed.local/");
209
+ const base = basePath();
210
+ const pathname = base ? `${base}${url.pathname === "/" ? "/" : url.pathname}` : url.pathname;
211
+ return `${pathname}${url.search}${url.hash}`;
212
+ }
213
+
214
+ function hrefForRoute(to) {
215
+ const value = String(to ?? "");
216
+ if (!value) {
217
+ return browserHrefForAppHref(currentAppLocation().href);
218
+ }
219
+
220
+ if (isExternalHref(value)) {
221
+ return value;
222
+ }
223
+
224
+ const current = currentAppLocation();
225
+ const resolved = new URL(value, `http://lakebed.local${current.href}`);
226
+ return browserHrefForAppHref(`${resolved.pathname}${resolved.search}${resolved.hash}`);
227
+ }
228
+
229
+ function emitLocationChange() {
230
+ window.dispatchEvent(new Event("lakebed:locationchange"));
231
+ }
232
+
233
+ export function navigate(to, options = {}) {
234
+ const href = hrefForRoute(to);
235
+ const parsed = new URL(href, window.location.href);
236
+
237
+ if (parsed.origin !== window.location.origin) {
238
+ window.location.assign(parsed.toString());
239
+ return;
240
+ }
241
+
242
+ const next = `${parsed.pathname}${parsed.search}${parsed.hash}`;
243
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
244
+ if (next === current) {
245
+ return;
246
+ }
247
+
248
+ if (options.replace) {
249
+ window.history.replaceState({}, "", next);
250
+ } else {
251
+ window.history.pushState({}, "", next);
252
+ }
253
+ emitLocationChange();
254
+ }
255
+
256
+ function useBrowserLocation() {
257
+ const [location, setLocation] = useState(currentAppLocation);
258
+
259
+ useEffect(() => {
260
+ function updateLocation() {
261
+ setLocation(currentAppLocation());
262
+ }
263
+
264
+ window.addEventListener("popstate", updateLocation);
265
+ window.addEventListener("lakebed:locationchange", updateLocation);
266
+ return () => {
267
+ window.removeEventListener("popstate", updateLocation);
268
+ window.removeEventListener("lakebed:locationchange", updateLocation);
269
+ };
270
+ }, []);
271
+
272
+ return location;
273
+ }
274
+
275
+ function normalizeMatchPath(path) {
276
+ const value = String(path ?? "/").trim();
277
+ if (value === "*" || value === "/*") {
278
+ return "*";
279
+ }
280
+
281
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
282
+ return withSlash.length > 1 ? withSlash.replace(/\/+$/g, "") : "/";
283
+ }
284
+
285
+ function pathSegments(path) {
286
+ const normalized = normalizeMatchPath(path);
287
+ if (normalized === "*" || normalized === "/") {
288
+ return [];
289
+ }
290
+
291
+ return normalized.replace(/^\/+|\/+$/g, "").split("/");
292
+ }
293
+
294
+ function decodeRouteSegment(value) {
295
+ try {
296
+ return decodeURIComponent(value);
297
+ } catch {
298
+ return value;
299
+ }
300
+ }
301
+
302
+ function matchRoutePath(pattern, pathname) {
303
+ const normalizedPattern = normalizeMatchPath(pattern);
304
+ if (normalizedPattern === "*") {
305
+ return { params: {} };
306
+ }
307
+
308
+ const patternSegments = pathSegments(normalizedPattern);
309
+ const pathnameSegments = pathSegments(pathname);
310
+ const params = {};
311
+
312
+ for (let index = 0; index < patternSegments.length; index += 1) {
313
+ const patternSegment = patternSegments[index];
314
+ const pathnameSegment = pathnameSegments[index];
315
+
316
+ if (patternSegment === "*") {
317
+ params["*"] = pathnameSegments.slice(index).map(decodeRouteSegment).join("/");
318
+ return { params };
319
+ }
320
+
321
+ if (pathnameSegment === undefined) {
322
+ return null;
323
+ }
324
+
325
+ if (patternSegment.startsWith(":")) {
326
+ const name = patternSegment.slice(1);
327
+ if (!name) {
328
+ return null;
329
+ }
330
+ params[name] = decodeRouteSegment(pathnameSegment);
331
+ continue;
332
+ }
333
+
334
+ if (patternSegment !== pathnameSegment) {
335
+ return null;
336
+ }
337
+ }
338
+
339
+ if (patternSegments.length !== pathnameSegments.length) {
340
+ return null;
341
+ }
342
+
343
+ return { params };
344
+ }
345
+
346
+ function routeChildren(children) {
347
+ const routes = [];
348
+ for (const child of toChildArray(children)) {
349
+ if (!child || typeof child !== "object") {
350
+ continue;
351
+ }
352
+
353
+ if (child.props?.path !== undefined) {
354
+ routes.push(child);
355
+ continue;
356
+ }
357
+
358
+ if (child.props?.children !== undefined) {
359
+ routes.push(...routeChildren(child.props.children));
360
+ }
361
+ }
362
+ return routes;
363
+ }
364
+
365
+ function shouldHandleLinkClick(event, target) {
366
+ return (
367
+ !event.defaultPrevented &&
368
+ event.button === 0 &&
369
+ !event.altKey &&
370
+ !event.ctrlKey &&
371
+ !event.metaKey &&
372
+ !event.shiftKey &&
373
+ (!target || target === "_self") &&
374
+ !event.currentTarget?.hasAttribute("download")
375
+ );
376
+ }
377
+
162
378
  function normalizeReturnTo(value) {
163
379
  if (!value) {
164
380
  return null;
@@ -746,6 +962,72 @@ export function SignInWithGoogle({
746
962
  );
747
963
  }
748
964
 
965
+ export function Router({ children } = {}) {
966
+ const location = useBrowserLocation();
967
+ return h(RouterContext.Provider, { value: { location, navigate } }, children);
968
+ }
969
+
970
+ export function Routes({ children } = {}) {
971
+ const location = useLocation();
972
+ const routes = routeChildren(children);
973
+ for (const route of routes) {
974
+ const match = matchRoutePath(route.props.path, location.pathname);
975
+ if (!match) {
976
+ continue;
977
+ }
978
+
979
+ return h(RouteContext.Provider, { value: match }, route.props.element ?? null);
980
+ }
981
+
982
+ return null;
983
+ }
984
+
985
+ export function Route() {
986
+ return null;
987
+ }
988
+
989
+ export function Link({ children, onClick, replace = false, target, to, ...props } = {}) {
990
+ const href = hrefForRoute(to);
991
+ return h(
992
+ "a",
993
+ {
994
+ ...props,
995
+ href,
996
+ onClick: (event) => {
997
+ onClick?.(event);
998
+ if (!shouldHandleLinkClick(event, target)) {
999
+ return;
1000
+ }
1001
+
1002
+ const parsed = new URL(href, window.location.href);
1003
+ if (parsed.origin !== window.location.origin) {
1004
+ return;
1005
+ }
1006
+
1007
+ event.preventDefault();
1008
+ navigate(to, { replace });
1009
+ },
1010
+ target
1011
+ },
1012
+ children
1013
+ );
1014
+ }
1015
+
1016
+ export function useLocation() {
1017
+ const context = useContext(RouterContext);
1018
+ const fallback = useBrowserLocation();
1019
+ return context?.location ?? fallback;
1020
+ }
1021
+
1022
+ export function useNavigate() {
1023
+ const context = useContext(RouterContext);
1024
+ return context?.navigate ?? navigate;
1025
+ }
1026
+
1027
+ export function useParams() {
1028
+ return useContext(RouteContext).params;
1029
+ }
1030
+
749
1031
  export function useQuery(name) {
750
1032
  const [value, setValue] = useState(queryValues.get(name) ?? []);
751
1033
 
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.19";
1
+ export const LAKEBED_VERSION = "0.0.20";