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 +1 -1
- package/src/anonymous-server.js +28 -0
- package/src/cli.js +114 -33
- package/src/client.d.ts +38 -0
- package/src/client.js +285 -3
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/anonymous-server.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
<
|
|
1899
|
-
<
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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
|
-
|
|
1906
|
-
<
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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.
|
|
1
|
+
export const LAKEBED_VERSION = "0.0.20";
|