@pracht/core 0.2.5 → 0.2.6

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 CHANGED
@@ -35,16 +35,16 @@ metadata for the failure phase and matched framework files when available.
35
35
  ### Client
36
36
 
37
37
  - `startApp()` — client-side hydration and runtime
38
+ - `useLocation()` — access the current pathname and search string separately
38
39
  - `useRouteData()` — access loader data inside a route component
39
40
  - `useRevalidateRoute()` — trigger a revalidation of the current route's data
40
- - `useSubmitAction()` — submit a form action programmatically
41
41
  - `<Form>` — progressive enhancement form component
42
42
 
43
43
  ### Types
44
44
 
45
45
  - `LoaderData<T>` — infer the return type of a loader
46
46
  - `RouteComponentProps<T>` — props type for route components
47
- - `LoaderArgs` — argument type passed to loaders and actions
47
+ - `LoaderArgs` — argument type passed to loaders
48
48
 
49
49
  ## Rendering Modes
50
50
 
@@ -216,7 +216,7 @@ function resolveApiRoutes(files, apiDir = "/src/api") {
216
216
  file,
217
217
  segments: parseRouteSegments(path)
218
218
  };
219
- });
219
+ }).sort(compareResolvedApiRoutes);
220
220
  }
221
221
  function matchApiRoute(apiRoutes, pathname) {
222
222
  const normalizedPathname = normalizeRoutePath(pathname);
@@ -237,5 +237,23 @@ function createRouteId(path) {
237
237
  return segment.startsWith(":") ? segment.slice(1) : segment;
238
238
  }).join("-").replace(/[^a-zA-Z0-9-]/g, "-");
239
239
  }
240
+ function compareResolvedApiRoutes(left, right) {
241
+ const length = Math.max(left.segments.length, right.segments.length);
242
+ for (let index = 0; index < length; index += 1) {
243
+ const leftSegment = left.segments[index];
244
+ const rightSegment = right.segments[index];
245
+ if (!leftSegment) return 1;
246
+ if (!rightSegment) return -1;
247
+ const leftScore = getRouteSegmentSpecificity(leftSegment);
248
+ const rightScore = getRouteSegmentSpecificity(rightSegment);
249
+ if (leftScore !== rightScore) return rightScore - leftScore;
250
+ }
251
+ return left.path.localeCompare(right.path);
252
+ }
253
+ function getRouteSegmentSpecificity(segment) {
254
+ if (segment.type === "static") return 3;
255
+ if (segment.type === "param") return 2;
256
+ return 1;
257
+ }
240
258
  //#endregion
241
259
  export { matchApiRoute as a, resolveApp as c, group as i, route as l, buildPathFromSegments as n, matchAppRoute as o, defineApp as r, resolveApiRoutes as s, app_exports as t, timeRevalidate as u };
package/dist/index.d.mts CHANGED
@@ -334,6 +334,7 @@ declare function readHydrationState<TData = unknown>(): PrachtHydrationState<TDa
334
334
  declare function useRouteData<TData = unknown>(): TData;
335
335
  interface Location {
336
336
  pathname: string;
337
+ search: string;
337
338
  }
338
339
  declare function useLocation(): Location;
339
340
  declare function useParams(): RouteParams;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as matchApiRoute, c as resolveApp, i as group, l as route, n as buildPathFromSegments, o as matchAppRoute, r as defineApp, s as resolveApiRoutes, u as timeRevalidate } from "./app-Cep0el7c.mjs";
1
+ import { a as matchApiRoute, c as resolveApp, i as group, l as route, n as buildPathFromSegments, o as matchAppRoute, r as defineApp, s as resolveApiRoutes, u as timeRevalidate } from "./app-CUL3q2ZA.mjs";
2
2
  import { createContext, h, hydrate, options, render } from "preact";
3
3
  import { useContext, useEffect, useMemo, useState } from "preact/hooks";
4
4
  import { Suspense, lazy } from "preact-suspense";
@@ -137,7 +137,7 @@ function useRouteData() {
137
137
  return useContext(RouteDataContext)?.data;
138
138
  }
139
139
  function useLocation() {
140
- return { pathname: useContext(RouteDataContext)?.url ?? (typeof window !== "undefined" ? window.location.pathname : "/") };
140
+ return parseLocation(useContext(RouteDataContext)?.url ?? (typeof window !== "undefined" ? window.location.pathname + window.location.search : "/"));
141
141
  }
142
142
  function useParams() {
143
143
  return useContext(RouteDataContext)?.params ?? {};
@@ -200,6 +200,10 @@ async function fetchPrachtRouteState(url) {
200
200
  type: "redirect"
201
201
  };
202
202
  const json = await response.json();
203
+ if (json.redirect) return {
204
+ location: json.redirect,
205
+ type: "redirect"
206
+ };
203
207
  if (!response.ok) {
204
208
  if (json.error) return {
205
209
  error: json.error,
@@ -214,6 +218,7 @@ async function fetchPrachtRouteState(url) {
214
218
  }
215
219
  async function handlePrachtRequest(options) {
216
220
  const url = new URL(options.request.url);
221
+ const requestPath = getRequestPath(url);
217
222
  const registry = options.registry ?? {};
218
223
  const isRouteStateRequest = options.request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
219
224
  const exposeDiagnostics = shouldExposeServerErrors(options);
@@ -315,7 +320,7 @@ async function handlePrachtRequest(options) {
315
320
  route: match.route,
316
321
  url
317
322
  });
318
- if (middlewareResult.response) return withRouteResponseHeaders(middlewareResult.response, { isRouteStateRequest });
323
+ if (middlewareResult.response) return normalizePageResponse(middlewareResult.response, { isRouteStateRequest });
319
324
  routeArgs = {
320
325
  ...routeArgs,
321
326
  context: middlewareResult.context
@@ -327,7 +332,7 @@ async function handlePrachtRequest(options) {
327
332
  const { loader, loaderFile: resolvedLoaderFile } = await resolveDataFunctions(match.route, routeModule, registry);
328
333
  loaderFile = resolvedLoaderFile;
329
334
  const loaderResult = loader ? await loader(routeArgs) : void 0;
330
- if (loaderResult instanceof Response) return withRouteResponseHeaders(loaderResult, { isRouteStateRequest });
335
+ if (loaderResult instanceof Response) return normalizePageResponse(loaderResult, { isRouteStateRequest });
331
336
  const data = loaderResult;
332
337
  if (isRouteStateRequest) return withRouteResponseHeaders(Response.json({ data }), { isRouteStateRequest: true });
333
338
  currentPhase = "render";
@@ -348,7 +353,7 @@ async function handlePrachtRequest(options) {
348
353
  head,
349
354
  body,
350
355
  hydrationState: {
351
- url: url.pathname,
356
+ url: requestPath,
352
357
  routeId: match.route.id ?? "",
353
358
  data: null,
354
359
  error: null,
@@ -372,13 +377,13 @@ async function handlePrachtRequest(options) {
372
377
  data,
373
378
  params: match.params,
374
379
  routeId: match.route.id ?? "",
375
- url: url.pathname
380
+ url: requestPath
376
381
  }, componentTree);
377
382
  return htmlResponse(buildHtmlDocument({
378
383
  head,
379
384
  body: await (await getRenderToStringAsync())(tree),
380
385
  hydrationState: {
381
- url: url.pathname,
386
+ url: requestPath,
382
387
  routeId: match.route.id ?? "",
383
388
  data,
384
389
  error: null
@@ -399,10 +404,27 @@ async function handlePrachtRequest(options) {
399
404
  routeModule,
400
405
  shellFile: match.route.shellFile,
401
406
  shellModule,
402
- urlPathname: url.pathname
407
+ requestPath
403
408
  });
404
409
  }
405
410
  }
411
+ function parseLocation(value) {
412
+ try {
413
+ const url = new URL(value, "http://pracht.local");
414
+ return {
415
+ pathname: url.pathname,
416
+ search: url.search
417
+ };
418
+ } catch {
419
+ return {
420
+ pathname: value || "/",
421
+ search: ""
422
+ };
423
+ }
424
+ }
425
+ function getRequestPath(url) {
426
+ return `${url.pathname}${url.search}`;
427
+ }
406
428
  /** Strip leading `./` and `/` so all module paths share one canonical form. */
407
429
  function normalizeModulePath(path) {
408
430
  return path.replace(/^\.?\//, "");
@@ -553,6 +575,24 @@ function jsonErrorResponse(routeError, options) {
553
575
  headers
554
576
  });
555
577
  }
578
+ function jsonRedirectResponse(location, options) {
579
+ const headers = new Headers(options.headers);
580
+ headers.set("content-type", "application/json; charset=utf-8");
581
+ return withRouteResponseHeaders(new Response(JSON.stringify({ redirect: location }), {
582
+ status: 200,
583
+ headers
584
+ }), { isRouteStateRequest: options.isRouteStateRequest });
585
+ }
586
+ function normalizePageResponse(response, options) {
587
+ if (options.isRouteStateRequest && response.status >= 300 && response.status < 400) {
588
+ const location = response.headers.get("location");
589
+ if (location) return jsonRedirectResponse(location, {
590
+ headers: response.headers,
591
+ isRouteStateRequest: true
592
+ });
593
+ }
594
+ return withRouteResponseHeaders(response, options);
595
+ }
556
596
  function renderApiErrorResponse(options) {
557
597
  const exposeDetails = shouldExposeServerErrors(options.options);
558
598
  const routeError = normalizeRouteError(options.error, { exposeDetails });
@@ -610,10 +650,10 @@ async function renderRouteErrorResponse(options) {
610
650
  body: await renderToString(h(PrachtRuntimeProvider, {
611
651
  data: null,
612
652
  routeId: options.routeId,
613
- url: options.urlPathname
653
+ url: options.requestPath
614
654
  }, componentTree)),
615
655
  hydrationState: {
616
- url: options.urlPathname,
656
+ url: options.requestPath,
617
657
  routeId: options.routeId,
618
658
  data: null,
619
659
  error: routeErrorWithDiagnostics
@@ -784,7 +824,7 @@ function serializeJsonForHtml(value) {
784
824
  return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
785
825
  }
786
826
  async function prerenderApp(options) {
787
- const { resolveApp } = await import("./app-Cep0el7c.mjs").then((n) => n.t);
827
+ const { resolveApp } = await import("./app-CUL3q2ZA.mjs").then((n) => n.t);
788
828
  const resolved = resolveApp(options.app);
789
829
  const results = [];
790
830
  const isgManifest = {};
@@ -846,7 +886,7 @@ async function collectSSGPaths(route, registry) {
846
886
  console.warn(` Warning: SSG route "${route.path}" has dynamic segments but no getStaticPaths() export, skipping.`);
847
887
  return [];
848
888
  }
849
- const { buildPathFromSegments } = await import("./app-Cep0el7c.mjs").then((n) => n.t);
889
+ const { buildPathFromSegments } = await import("./app-CUL3q2ZA.mjs").then((n) => n.t);
850
890
  return (await routeModule.getStaticPaths()).map((params) => buildPathFromSegments(route.segments, params));
851
891
  }
852
892
  //#endregion
@@ -874,6 +914,13 @@ function prefetchRouteState(url) {
874
914
  }
875
915
  function setupPrefetching(app, warmModules) {
876
916
  let hoverTimer = null;
917
+ function getRoutePathname(url) {
918
+ try {
919
+ return new URL(url, window.location.origin).pathname;
920
+ } catch {
921
+ return null;
922
+ }
923
+ }
877
924
  function getInternalHref(anchor) {
878
925
  const href = anchor.getAttribute("href");
879
926
  if (!href || href.startsWith("#")) return null;
@@ -887,7 +934,9 @@ function setupPrefetching(app, warmModules) {
887
934
  return url.pathname + url.search;
888
935
  }
889
936
  function getPrefetchStrategy(pathname) {
890
- const match = matchAppRoute(app, pathname);
937
+ const routePathname = getRoutePathname(pathname);
938
+ if (!routePathname) return "none";
939
+ const match = matchAppRoute(app, routePathname);
891
940
  if (!match) return "none";
892
941
  if (match.route.prefetch) return match.route.prefetch;
893
942
  if (match.route.render === "spa") return "none";
@@ -904,7 +953,8 @@ function setupPrefetching(app, warmModules) {
904
953
  hoverTimer = setTimeout(() => {
905
954
  prefetchRouteState(href);
906
955
  if (warmModules) {
907
- const m = matchAppRoute(app, href);
956
+ const pathname = getRoutePathname(href);
957
+ const m = pathname ? matchAppRoute(app, pathname) : void 0;
908
958
  if (m) warmModules(m);
909
959
  }
910
960
  }, 50);
@@ -925,7 +975,8 @@ function setupPrefetching(app, warmModules) {
925
975
  if (strategy !== "hover" && strategy !== "intent") return;
926
976
  prefetchRouteState(href);
927
977
  if (warmModules) {
928
- const m = matchAppRoute(app, href);
978
+ const pathname = getRoutePathname(href);
979
+ const m = pathname ? matchAppRoute(app, pathname) : void 0;
929
980
  if (m) warmModules(m);
930
981
  }
931
982
  }, true);
@@ -938,7 +989,8 @@ function setupPrefetching(app, warmModules) {
938
989
  if (!href) continue;
939
990
  prefetchRouteState(href);
940
991
  if (warmModules) {
941
- const m = matchAppRoute(app, href);
992
+ const pathname = getRoutePathname(href);
993
+ const m = pathname ? matchAppRoute(app, pathname) : void 0;
942
994
  if (m) warmModules(m);
943
995
  }
944
996
  observer.unobserve(anchor);
@@ -989,7 +1041,7 @@ async function initClientRouter(options) {
989
1041
  if (!shellKey) return null;
990
1042
  return loadModule(shellModules, shellKey);
991
1043
  }
992
- async function buildRouteTree(match, state, routeModPromise, shellModPromise) {
1044
+ async function buildRouteTree(match, state, currentUrl, routeModPromise, shellModPromise) {
993
1045
  const routeMod = await (routeModPromise ?? startRouteImport(match));
994
1046
  if (!routeMod) return null;
995
1047
  let Shell = null;
@@ -1007,10 +1059,10 @@ async function initClientRouter(options) {
1007
1059
  data: state.data,
1008
1060
  params: match.params,
1009
1061
  routeId: match.route.id ?? "",
1010
- url: match.pathname
1062
+ url: currentUrl
1011
1063
  }, componentTree));
1012
1064
  }
1013
- async function buildSpaPendingTree(match, shellModPromise) {
1065
+ async function buildSpaPendingTree(match, currentUrl, shellModPromise) {
1014
1066
  const resolvedShell = await (shellModPromise ?? startShellImport(match));
1015
1067
  if (!resolvedShell) return null;
1016
1068
  const Shell = resolvedShell.Shell;
@@ -1021,16 +1073,40 @@ async function initClientRouter(options) {
1021
1073
  data: void 0,
1022
1074
  params: match.params,
1023
1075
  routeId: match.route.id ?? "",
1024
- url: match.pathname
1076
+ url: currentUrl
1025
1077
  }, componentTree));
1026
1078
  }
1079
+ function resolveRedirectTarget(location) {
1080
+ const targetUrl = new URL(location, window.location.href);
1081
+ const fullInternalTarget = targetUrl.pathname + targetUrl.search + targetUrl.hash;
1082
+ const internalPath = targetUrl.pathname + targetUrl.search;
1083
+ const currentPath = window.location.pathname + window.location.search + window.location.hash;
1084
+ const isCurrentLocation = targetUrl.origin === window.location.origin && fullInternalTarget === currentPath;
1085
+ if (targetUrl.origin !== window.location.origin) return {
1086
+ externalUrl: targetUrl.toString(),
1087
+ isCurrentLocation: false
1088
+ };
1089
+ if (targetUrl.hash) return {
1090
+ documentUrl: targetUrl.toString(),
1091
+ isCurrentLocation
1092
+ };
1093
+ return {
1094
+ internalPath,
1095
+ isCurrentLocation
1096
+ };
1097
+ }
1027
1098
  async function navigate(to, opts) {
1028
- const match = matchAppRoute(app, to);
1029
- if (!match) {
1099
+ const target = resolveBrowserRouteTarget(to);
1100
+ if (!target) {
1030
1101
  window.location.href = to;
1031
1102
  return;
1032
1103
  }
1033
- const statePromise = getCachedRouteState(to) ?? fetchPrachtRouteState(to);
1104
+ const match = matchAppRoute(app, target.pathname);
1105
+ if (!match) {
1106
+ window.location.href = target.browserUrl;
1107
+ return;
1108
+ }
1109
+ const statePromise = getCachedRouteState(target.requestUrl) ?? fetchPrachtRouteState(target.requestUrl);
1034
1110
  const routeModPromise = startRouteImport(match);
1035
1111
  const shellModPromise = startShellImport(match);
1036
1112
  let state = {
@@ -1041,10 +1117,24 @@ async function initClientRouter(options) {
1041
1117
  const result = await statePromise;
1042
1118
  if (result.type === "redirect") {
1043
1119
  if (result.location) {
1044
- await navigate(result.location, opts);
1120
+ const redirect = resolveRedirectTarget(result.location);
1121
+ if (redirect.externalUrl) {
1122
+ window.location.href = redirect.externalUrl;
1123
+ return;
1124
+ }
1125
+ if (redirect.isCurrentLocation) return;
1126
+ if (redirect.documentUrl) {
1127
+ window.location.href = redirect.documentUrl;
1128
+ return;
1129
+ }
1130
+ if (redirect.internalPath) {
1131
+ await navigate(redirect.internalPath, opts);
1132
+ return;
1133
+ }
1134
+ window.location.href = result.location;
1045
1135
  return;
1046
1136
  }
1047
- window.location.href = to;
1137
+ window.location.href = target.browserUrl;
1048
1138
  return;
1049
1139
  }
1050
1140
  if (result.type === "error") state = {
@@ -1056,16 +1146,16 @@ async function initClientRouter(options) {
1056
1146
  error: null
1057
1147
  };
1058
1148
  } catch {
1059
- window.location.href = to;
1149
+ window.location.href = target.browserUrl;
1060
1150
  return;
1061
1151
  }
1062
- if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", to);
1063
- else history.pushState(null, "", to);
1064
- const tree = await buildRouteTree(match, state, routeModPromise, shellModPromise);
1152
+ if (!opts?._popstate) if (opts?.replace) history.replaceState(null, "", target.browserUrl);
1153
+ else history.pushState(null, "", target.browserUrl);
1154
+ const tree = await buildRouteTree(match, state, target.requestUrl, routeModPromise, shellModPromise);
1065
1155
  if (tree) {
1066
1156
  render(tree, root);
1067
1157
  window.scrollTo(0, 0);
1068
- } else window.location.href = to;
1158
+ } else window.location.href = target.browserUrl;
1069
1159
  }
1070
1160
  if (import.meta.env?.DEV) {
1071
1161
  const prev = options.__m;
@@ -1076,7 +1166,10 @@ async function initClientRouter(options) {
1076
1166
  if (prev) prev(vnode, s);
1077
1167
  };
1078
1168
  }
1079
- const initialMatch = matchAppRoute(app, options.initialState.url);
1169
+ const initialTarget = resolveBrowserRouteTarget(options.initialState.url);
1170
+ const initialRequestUrl = initialTarget?.requestUrl ?? options.initialState.url;
1171
+ const initialBrowserUrl = initialTarget?.browserUrl ?? options.initialState.url;
1172
+ const initialMatch = matchAppRoute(app, initialTarget?.pathname ?? options.initialState.url);
1080
1173
  if (initialMatch) {
1081
1174
  const initialShellPromise = initialMatch.route.render === "spa" && options.initialState.pending ? startShellImport(initialMatch) : null;
1082
1175
  let state = {
@@ -1084,8 +1177,8 @@ async function initClientRouter(options) {
1084
1177
  error: options.initialState.error ?? null
1085
1178
  };
1086
1179
  if (initialMatch.route.render === "spa" && options.initialState.pending) {
1087
- const dataPromise = fetchPrachtRouteState(options.initialState.url);
1088
- const pendingTree = await buildSpaPendingTree(initialMatch, initialShellPromise);
1180
+ const dataPromise = fetchPrachtRouteState(initialRequestUrl);
1181
+ const pendingTree = await buildSpaPendingTree(initialMatch, initialRequestUrl, initialShellPromise);
1089
1182
  if (pendingTree) hydrate(pendingTree, root);
1090
1183
  try {
1091
1184
  const result = await dataPromise;
@@ -1102,11 +1195,11 @@ async function initClientRouter(options) {
1102
1195
  error: null
1103
1196
  };
1104
1197
  } catch {
1105
- window.location.href = options.initialState.url;
1198
+ window.location.href = initialBrowserUrl;
1106
1199
  return;
1107
1200
  }
1108
1201
  }
1109
- const tree = await buildRouteTree(initialMatch, state, void 0, initialShellPromise);
1202
+ const tree = await buildRouteTree(initialMatch, state, initialRequestUrl, void 0, initialShellPromise);
1110
1203
  if (tree) if (initialMatch.route.render === "spa") render(tree, root);
1111
1204
  else {
1112
1205
  markHydrating();
@@ -1132,10 +1225,10 @@ async function initClientRouter(options) {
1132
1225
  }
1133
1226
  if (url.origin !== window.location.origin) return;
1134
1227
  e.preventDefault();
1135
- navigate(url.pathname + url.search);
1228
+ navigate(url.pathname + url.search + url.hash);
1136
1229
  });
1137
1230
  window.addEventListener("popstate", () => {
1138
- navigate(window.location.pathname + window.location.search, { _popstate: true });
1231
+ navigate(window.location.pathname + window.location.search + window.location.hash, { _popstate: true });
1139
1232
  });
1140
1233
  window.__PRACHT_NAVIGATE__ = navigate;
1141
1234
  window.__PRACHT_ROUTER_READY__ = true;
@@ -1200,6 +1293,20 @@ function deserializeRouteError(error) {
1200
1293
  result.diagnostics = error.diagnostics;
1201
1294
  return result;
1202
1295
  }
1296
+ function resolveBrowserRouteTarget(to) {
1297
+ if (typeof window === "undefined") return null;
1298
+ try {
1299
+ const url = new URL(to, window.location.href);
1300
+ if (url.origin !== window.location.origin) return null;
1301
+ return {
1302
+ browserUrl: url.pathname + url.search + url.hash,
1303
+ pathname: url.pathname,
1304
+ requestUrl: url.pathname + url.search
1305
+ };
1306
+ } catch {
1307
+ return null;
1308
+ }
1309
+ }
1203
1310
  //#endregion
1204
1311
  //#region src/types.ts
1205
1312
  var PrachtHttpError = class extends Error {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/core",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "license": "MIT",
5
5
  "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/framework",
6
6
  "bugs": {