bosia 1.1.6 → 1.2.1

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/ambient.d.ts +3 -0
  5. package/src/cli/sync.ts +14 -1
  6. package/src/core/appHtml.ts +52 -0
  7. package/src/core/build.ts +4 -3
  8. package/src/core/client/App.svelte +82 -12
  9. package/src/core/client/TitleProbe.svelte +15 -0
  10. package/src/core/client/router.svelte.ts +1 -2
  11. package/src/core/client/titleMemory.svelte.ts +64 -0
  12. package/src/core/headScan.ts +132 -0
  13. package/src/core/html.ts +103 -45
  14. package/src/core/renderer.ts +190 -91
  15. package/src/core/routeFile.ts +15 -2
  16. package/src/core/scanner.ts +28 -31
  17. package/src/core/server.ts +95 -80
  18. package/src/lib/utils.ts +1 -8
  19. package/templates/default/package.json +2 -2
  20. package/templates/demo/package.json +2 -2
  21. package/templates/shop/package.json +2 -2
  22. package/templates/shop/src/routes/(private)/dashboard/files/+page.svelte +9 -0
  23. package/templates/shop/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
  24. package/templates/shop/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
  25. package/templates/shop/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
  26. package/templates/shop/src/routes/(private)/dashboard/products/+page.svelte +9 -0
  27. package/templates/shop/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
  28. package/templates/shop/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
  29. package/templates/shop/src/routes/(private)/dashboard/users/+page.svelte +9 -0
  30. package/templates/shop/src/routes/(public)/+page.svelte +2 -2
  31. package/templates/store/package.json +2 -2
  32. package/templates/store/src/routes/(private)/dashboard/files/+page.svelte +9 -0
  33. package/templates/store/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
  34. package/templates/store/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
  35. package/templates/store/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
  36. package/templates/store/src/routes/(private)/dashboard/products/+page.svelte +9 -0
  37. package/templates/store/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
  38. package/templates/store/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
  39. package/templates/store/src/routes/(private)/dashboard/users/+page.svelte +9 -0
  40. package/templates/store/src/routes/(public)/+page.svelte +2 -2
@@ -33,6 +33,7 @@ import {
33
33
  compress,
34
34
  compressBytes,
35
35
  encodeForRequest,
36
+ appHtmlHasTitle,
36
37
  isDev,
37
38
  withPreloadLink,
38
39
  type Encoded,
@@ -44,6 +45,7 @@ import { reportDevErrorFromCatch } from "./devErrorReport.ts";
44
45
  import { dev500Response } from "./dev-500.ts";
45
46
  import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
46
47
  import { getAppHtmlSegments } from "./appHtml.ts";
48
+ import { hasTitle } from "./headScan.ts";
47
49
  import type { AppHtmlSegments } from "./appHtml.ts";
48
50
 
49
51
  // Shared, stateless — one instance instead of a fresh allocation per stream.
@@ -144,7 +146,9 @@ function parseTimeout(raw: string | undefined, fallback: number): number {
144
146
  const LOAD_TIMEOUT = parseTimeout(process.env.LOAD_TIMEOUT, 5000);
145
147
  const METADATA_TIMEOUT = parseTimeout(process.env.METADATA_TIMEOUT, 3000);
146
148
 
147
- function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
149
+ // A plain-function load() hands back a value, not a promise; wrap it first.
150
+ function withTimeout<T>(value: T | Promise<T>, ms: number, label: string): Promise<T> {
151
+ const promise = Promise.resolve(value);
148
152
  if (ms <= 0) return promise;
149
153
  let timer: Timer;
150
154
  return Promise.race([
@@ -354,10 +358,45 @@ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
354
358
  };
355
359
  }
356
360
 
361
+ // Dev only: a layout threw redirect() or error(401/403) — a guard — while
362
+ // loaders below it (nested layouts, page load(), metadata()) had already
363
+ // started, since they all run in parallel. Their data is dropped; anything they
364
+ // wrote is not. This warns about where the guard lives, not about what the
365
+ // code below did: telling a read from a write, or an auth guard from a locale
366
+ // redirect, isn't possible from here. Hooks run before any loader.
367
+ // Once per route, depth and guard, so a guarded page doesn't flood the console.
368
+ const warnedGuards = new Set<string>();
369
+
370
+ // "redirect()" / "error(403)" when `err` is a guard, else null.
371
+ function guardLabel(err: unknown): string | null {
372
+ if (isRedirect(err)) return "redirect()";
373
+ if (isHttpError(err) && (err.status === 401 || err.status === 403)) return `error(${err.status})`;
374
+ return null;
375
+ }
376
+
377
+ function warnLayoutGuard(routeId: string, depth: number, thrown: string) {
378
+ const key = `${routeId}|${depth}|${thrown}`;
379
+ if (warnedGuards.has(key)) return;
380
+ warnedGuards.add(key);
381
+ console.warn(
382
+ `⚠️ ${routeId}: a layout (depth ${depth}) threw ${thrown} as a guard, but the loaders below it\n` +
383
+ ` (nested layouts, page load(), metadata()) start in parallel and ran anyway.\n` +
384
+ ` Their data is dropped; any writes they made are not. For an auth guard, use src/hooks.server.ts.`,
385
+ );
386
+ }
387
+
357
388
  // ─── Route Data Loader ───────────────────────────────────
358
389
  // Runs layout + page server loaders for a given URL.
359
390
  // Used by both SSR and the /__bosia/data JSON endpoint.
360
391
  //
392
+ // Every loader starts at once (SvelteKit's model). `parent()` is what orders
393
+ // them: it waits for the layers above and returns their merged data, so only a
394
+ // loader that asks for it waits. Results are still settled root → leaf, so the
395
+ // root-most failure decides the response, exactly as when they ran in turn.
396
+ // The cost: page code that runs before `await parent()` runs even when a layout
397
+ // guard redirects — its output never ships, but its side effects happen. Guard
398
+ // in hooks, or `await parent()` first.
399
+ //
361
400
  // `mask` controls which layers' data the client data endpoint sends back:
362
401
  // - undefined → send everything (SSR, first nav)
363
402
  // - layouts[i] === false → the layout still RUNS, but its slot is emitted as
@@ -375,6 +414,8 @@ export type LoaderMask = {
375
414
  layouts: boolean[];
376
415
  };
377
416
 
417
+ type LayerResult = { data: Record<string, any>; deps: LoaderDeps };
418
+
378
419
  export async function loadRouteData(
379
420
  url: URL,
380
421
  locals: Record<string, any>,
@@ -392,25 +433,61 @@ export async function loadRouteData(
392
433
  const origin = url.origin;
393
434
  const layoutData: (Record<string, any> | null)[] = [];
394
435
  const layoutDeps: (LoaderDeps | null)[] = [];
395
- let parentData: Record<string, any> = {};
396
436
  // Shared across layout + page loaders so cross-loader duplicate
397
437
  // setHeaders() calls throw. Keys stored lowercased.
398
438
  const loaderHeaders: Record<string, string> = {};
399
439
  const setHeaders = makeSetHeaders(loaderHeaders);
400
440
 
401
- // Run layout server loaders root → leaf, each gets parent() data
441
+ // Merged data of the first `n` layout layers. A fresh object per call, so
442
+ // loaders cannot mutate what the others see.
443
+ const layers: Promise<LayerResult>[] = [];
444
+ const parentOf = (n: number) => async () => {
445
+ const above = await Promise.all(layers.slice(0, n));
446
+ return Object.assign({}, ...above.map((l) => l.data));
447
+ };
448
+
402
449
  for (const ls of route.layoutServers) {
403
- // Runs regardless of the mask — see the trust note above loadRouteData.
404
- const omit = mask && mask.layouts[ls.depth] === false;
405
- try {
450
+ const parent = parentOf(layers.length);
451
+ const layer = (async (): Promise<LayerResult> => {
406
452
  const mod = await ls.loader();
407
- if (typeof mod.load === "function" && !warmingUp) {
408
- // Snapshot per layer so loaders cannot mutate the shared accumulator,
409
- // preserving the same isolation semantics as the previous merge-on-call code.
410
- const snapshot = { ...parentData };
411
- const parent = async () => snapshot;
453
+ if (typeof mod.load !== "function" || warmingUp) return { data: {}, deps: emptyDeps() };
454
+ const deps = emptyDeps();
455
+ const data =
456
+ (await withTimeout(
457
+ mod.load({
458
+ params: trackedParams(params, deps),
459
+ route: { id: route.id },
460
+ url: trackedUrl(url, deps),
461
+ locals,
462
+ cookies: trackedCookies(cookies, deps),
463
+ parent,
464
+ fetch: trackedFetch(fetch, origin, deps),
465
+ metadata: null,
466
+ depends: makeDepends(deps),
467
+ platform: getPlatform(),
468
+ setHeaders,
469
+ }),
470
+ LOAD_TIMEOUT,
471
+ `layout load (depth=${ls.depth}, ${url.pathname})`,
472
+ )) ?? {};
473
+ return { data, deps };
474
+ })();
475
+ // Settled in order below; a rejection must not be reported as unhandled
476
+ // while an earlier layer is still being awaited.
477
+ layer.catch(() => {});
478
+ layers.push(layer);
479
+ }
480
+
481
+ const skipPage = mask && mask.page === false;
482
+ const pageP = route.pageServer
483
+ ? (async () => {
484
+ const mod = await route.pageServer!();
485
+ const flags = { csr: mod.csr !== false, ssr: mod.ssr !== false };
486
+ if (skipPage) return { ...flags, data: null, deps: null };
487
+ if (typeof mod.load !== "function" || warmingUp)
488
+ return { ...flags, data: {}, deps: emptyDeps() };
412
489
  const deps = emptyDeps();
413
- const result =
490
+ const data =
414
491
  (await withTimeout(
415
492
  mod.load({
416
493
  params: trackedParams(params, deps),
@@ -418,24 +495,61 @@ export async function loadRouteData(
418
495
  url: trackedUrl(url, deps),
419
496
  locals,
420
497
  cookies: trackedCookies(cookies, deps),
421
- parent,
498
+ parent: parentOf(layers.length),
422
499
  fetch: trackedFetch(fetch, origin, deps),
423
- metadata: null,
500
+ metadata: await metadataData,
424
501
  depends: makeDepends(deps),
425
502
  platform: getPlatform(),
426
503
  setHeaders,
427
504
  }),
428
505
  LOAD_TIMEOUT,
429
- `layout load (depth=${ls.depth}, ${url.pathname})`,
506
+ `page load (${url.pathname})`,
430
507
  )) ?? {};
431
- layoutData[ls.depth] = omit ? null : result;
432
- layoutDeps[ls.depth] = omit ? null : deps;
433
- parentData = { ...parentData, ...result };
434
- } else {
435
- layoutData[ls.depth] = omit ? null : {};
436
- layoutDeps[ls.depth] = omit ? null : emptyDeps();
437
- }
508
+ return {
509
+ ...flags,
510
+ data: data as Record<string, any> | null,
511
+ deps: deps as LoaderDeps | null,
512
+ };
513
+ })()
514
+ : null;
515
+ pageP?.catch(() => {});
516
+
517
+ // Dev only: does any loader below layer i run user code on this request?
518
+ // Asked of the modules (cached imports), not the route's shape: a page
519
+ // server with only `actions` runs nothing, while a masked page still runs
520
+ // metadata() when the caller started it.
521
+ const userCodeBelow = async (i: number) => {
522
+ for (const below of route.layoutServers.slice(i + 1)) {
523
+ if (typeof (await below.loader()).load === "function") return true;
524
+ }
525
+ if (!route.pageServer) return false;
526
+ const mod = await route.pageServer();
527
+ return (
528
+ (!skipPage && typeof mod.load === "function") ||
529
+ (metadataData !== null && typeof mod.metadata === "function")
530
+ );
531
+ };
532
+
533
+ // Settle root → leaf. The first failure wins, carrying the data of the
534
+ // layers above it — the same error a serial run would have produced.
535
+ for (let i = 0; i < route.layoutServers.length; i++) {
536
+ const ls = route.layoutServers[i]!;
537
+ // Runs regardless of the mask — see the trust note above loadRouteData.
538
+ const omit = mask && mask.layouts[ls.depth] === false;
539
+ try {
540
+ const { data, deps } = await layers[i]!;
541
+ layoutData[ls.depth] = omit ? null : data;
542
+ layoutDeps[ls.depth] = omit ? null : deps;
438
543
  } catch (err) {
544
+ // Loaders below this one already started; dev warns about the guard.
545
+ const thrown = isDev ? guardLabel(err) : null;
546
+ if (thrown) {
547
+ const depth = ls.depth;
548
+ userCodeBelow(i).then(
549
+ (ran) => ran && warnLayoutGuard(route.id, depth, thrown),
550
+ () => {},
551
+ );
552
+ }
439
553
  if (isRedirect(err)) throw err;
440
554
  if (isHttpError(err)) {
441
555
  stampErrorContext(
@@ -460,47 +574,13 @@ export async function loadRouteData(
460
574
  }
461
575
  }
462
576
 
463
- // Run page server loader
464
- let pageData: Record<string, any> | null = null;
465
- let pageDeps: LoaderDeps | null = null;
577
+ let pageData: Record<string, any> | null = {};
578
+ let pageDeps: LoaderDeps | null = emptyDeps();
466
579
  let csr = true;
467
580
  let ssr = true;
468
- const skipPage = mask && mask.page === false;
469
- if (route.pageServer) {
581
+ if (pageP) {
470
582
  try {
471
- const mod = await route.pageServer();
472
- if (mod.csr === false) csr = false;
473
- if (mod.ssr === false) ssr = false;
474
- if (skipPage) {
475
- pageData = null;
476
- pageDeps = null;
477
- } else if (typeof mod.load === "function" && !warmingUp) {
478
- const snapshot = { ...parentData };
479
- const parent = async () => snapshot;
480
- const deps = emptyDeps();
481
- pageData =
482
- (await withTimeout(
483
- mod.load({
484
- params: trackedParams(params, deps),
485
- route: { id: route.id },
486
- url: trackedUrl(url, deps),
487
- locals,
488
- cookies: trackedCookies(cookies, deps),
489
- parent,
490
- fetch: trackedFetch(fetch, origin, deps),
491
- metadata: await metadataData,
492
- depends: makeDepends(deps),
493
- platform: getPlatform(),
494
- setHeaders,
495
- }),
496
- LOAD_TIMEOUT,
497
- `page load (${url.pathname})`,
498
- )) ?? {};
499
- pageDeps = deps;
500
- } else {
501
- pageData = {};
502
- pageDeps = emptyDeps();
503
- }
583
+ ({ data: pageData, deps: pageDeps, csr, ssr } = await pageP);
504
584
  } catch (err) {
505
585
  if (isRedirect(err)) throw err;
506
586
  if (isHttpError(err)) {
@@ -524,9 +604,6 @@ export async function loadRouteData(
524
604
  );
525
605
  throw wrapped;
526
606
  }
527
- } else {
528
- pageData = {};
529
- pageDeps = emptyDeps();
530
607
  }
531
608
 
532
609
  // `params` are always attached to pageData for client-side router consumption.
@@ -655,8 +732,7 @@ export async function renderSSRStream(
655
732
  // ── Pre-stream phase: metadata(), loaders, module imports and plugin
656
733
  // fragments all start together, and all settle before committing to a 200,
657
734
  // so a Redirect/HttpError from any of them still gets a proper response.
658
- // Layout loaders still run in order before the page load(), which also
659
- // waits for metadata.data (see loadRouteData).
735
+ // The page load() alone waits for metadata.data (see loadRouteData).
660
736
  const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
661
737
  (value) => ({ ok: true as const, value }),
662
738
  (err: unknown) => ({ ok: false as const, err }),
@@ -795,9 +871,8 @@ export async function renderSSRStream(
795
871
  }
796
872
  const html =
797
873
  buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments, route.pattern) +
798
- buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
874
+ buildMetadataChunk(metadata, { headExtras, segments: appHtmlSegments, nonce }) +
799
875
  buildHtmlTail(
800
- "",
801
876
  "",
802
877
  pageDataFull,
803
878
  layoutDataFull,
@@ -809,6 +884,7 @@ export async function renderSSRStream(
809
884
  data.pageDeps,
810
885
  data.layoutDeps,
811
886
  appHtmlSegments,
887
+ metadata?.title,
812
888
  );
813
889
  return compress(
814
890
  html,
@@ -866,10 +942,9 @@ export async function renderSSRStream(
866
942
  appHtmlSegments,
867
943
  data.csr ? route.pattern : undefined,
868
944
  ) +
869
- buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
945
+ buildMetadataChunk(metadata, { headExtras, segments: appHtmlSegments, head, nonce }) +
870
946
  buildHtmlTail(
871
947
  body,
872
- head,
873
948
  pageDataFull,
874
949
  layoutDataFull,
875
950
  data.csr,
@@ -880,6 +955,7 @@ export async function renderSSRStream(
880
955
  data.pageDeps,
881
956
  data.layoutDeps,
882
957
  appHtmlSegments,
958
+ metadata?.title,
883
959
  ),
884
960
  ) as Uint8Array<ArrayBuffer>;
885
961
  // Stored with the cache entry too, so hits carry the same Link header.
@@ -972,11 +1048,34 @@ export async function renderPageWithFormData(
972
1048
 
973
1049
  const { route, params } = match;
974
1050
 
975
- // Serial, not parallel: metadata.data feeds the loader below.
1051
+ // Same overlap as the GET path: metadata() and the loaders start together,
1052
+ // and only the page load() waits for metadata.data. A metadata redirect or
1053
+ // error() still answers first.
1054
+ const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
1055
+ (value) => ({ ok: true as const, value }),
1056
+ (err: unknown) => ({ ok: false as const, err }),
1057
+ );
1058
+ const loadP = Promise.all([
1059
+ loadRouteData(
1060
+ url,
1061
+ locals,
1062
+ req,
1063
+ cookies,
1064
+ metadataP.then((m) => (m.ok ? (m.value?.data ?? null) : null)),
1065
+ match,
1066
+ ),
1067
+ route.pageModule(),
1068
+ Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
1069
+ ]);
1070
+ // Settled by the early returns below without being read.
1071
+ loadP.catch(() => {});
1072
+
976
1073
  let metadata: Metadata | null = null;
977
- try {
978
- metadata = await loadMetadata(route, params, url, locals, cookies, req);
979
- } catch (err) {
1074
+ const meta = await metadataP;
1075
+ if (meta.ok) {
1076
+ metadata = meta.value;
1077
+ } else {
1078
+ const err = meta.err;
980
1079
  if (isRedirect(err))
981
1080
  return new Response(null, { status: err.status, headers: { Location: err.location } });
982
1081
  if (isHttpError(err)) {
@@ -998,12 +1097,7 @@ export async function renderPageWithFormData(
998
1097
  // Continue with null metadata — don't break the page for a metadata failure
999
1098
  }
1000
1099
 
1001
- // Load components + data in parallel
1002
- const [data, pageMod, layoutMods] = await Promise.all([
1003
- loadRouteData(url, locals, req, cookies, metadata?.data ?? null, match),
1004
- route.pageModule(),
1005
- Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
1006
- ]);
1100
+ const [data, pageMod, layoutMods] = await loadP;
1007
1101
 
1008
1102
  if (!data)
1009
1103
  return renderErrorPage(
@@ -1028,13 +1122,6 @@ export async function renderPageWithFormData(
1028
1122
  pluginRenderFragments("head", renderCtx),
1029
1123
  pluginRenderFragments("bodyEnd", renderCtx),
1030
1124
  ]);
1031
- // buildHtml has no headExtras slot; fold them in ahead of the SSR head, which
1032
- // is where buildMetadataChunk puts them on the streaming path.
1033
- const headWithExtras = (ssrHead: string) => {
1034
- const extras = headExtras.filter(Boolean);
1035
- return extras.length ? `${extras.join("\n ")}\n ${ssrHead}` : ssrHead;
1036
- };
1037
-
1038
1125
  // Form-action re-render always runs every loader (no client mask).
1039
1126
  const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
1040
1127
  const pageDataFull = data.pageData ?? {};
@@ -1047,7 +1134,7 @@ export async function renderPageWithFormData(
1047
1134
  }
1048
1135
  const html = buildHtml(
1049
1136
  "",
1050
- headWithExtras(""),
1137
+ "",
1051
1138
  pageDataFull,
1052
1139
  layoutDataFull,
1053
1140
  true,
@@ -1061,6 +1148,7 @@ export async function renderPageWithFormData(
1061
1148
  appHtmlSegments,
1062
1149
  metadata,
1063
1150
  route.pattern,
1151
+ headExtras,
1064
1152
  );
1065
1153
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
1066
1154
  }
@@ -1082,7 +1170,7 @@ export async function renderPageWithFormData(
1082
1170
 
1083
1171
  const html = buildHtml(
1084
1172
  body,
1085
- headWithExtras(head),
1173
+ head,
1086
1174
  pageDataFull,
1087
1175
  layoutDataFull,
1088
1176
  data.csr,
@@ -1096,6 +1184,7 @@ export async function renderPageWithFormData(
1096
1184
  appHtmlSegments,
1097
1185
  metadata,
1098
1186
  route.pattern,
1187
+ headExtras,
1099
1188
  );
1100
1189
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
1101
1190
  }
@@ -1124,9 +1213,13 @@ export async function renderErrorPage(
1124
1213
  // The route's own metadata() is deliberately NOT run here — the route may be
1125
1214
  // what failed, and a 404 has no route at all. Synthesize a status title only
1126
1215
  // when the error component didn't set one, so <svelte:head><title> in a custom
1127
- // +error.svelte still wins.
1216
+ // +error.svelte still wins. It is the late fallback (after the component's
1217
+ // head, so a real title hasTitle misses still comes first), except when
1218
+ // app.html has its own <title>: that would come first, so the status title
1219
+ // takes the metadata slot ahead of it instead.
1220
+ const statusTitle = `${status} — ${message}`;
1128
1221
  const errMeta = (head: string): Metadata | null =>
1129
- head.includes("<title>") ? null : { title: `${status} — ${message}` };
1222
+ appHtmlHasTitle(appHtmlSegments) && !hasTitle(head) ? { title: statusTitle } : null;
1130
1223
 
1131
1224
  // Inspector overlay and other plugin bodyEnd fragments must be injected
1132
1225
  // on error pages too — otherwise SSE never connects and runtime errors
@@ -1135,7 +1228,7 @@ export async function renderErrorPage(
1135
1228
  request: req,
1136
1229
  url,
1137
1230
  route: route ? { pattern: route.pattern } : { pattern: "" },
1138
- metadata: errMeta(""),
1231
+ metadata: { title: statusTitle },
1139
1232
  };
1140
1233
  const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
1141
1234
 
@@ -1192,6 +1285,9 @@ export async function renderErrorPage(
1192
1285
  bodyEndExtras,
1193
1286
  appHtmlSegments,
1194
1287
  errMeta(head),
1288
+ undefined,
1289
+ undefined,
1290
+ statusTitle,
1195
1291
  );
1196
1292
  return compress(html, "text/html; charset=utf-8", req, status);
1197
1293
  } catch (err) {
@@ -1230,6 +1326,9 @@ export async function renderErrorPage(
1230
1326
  bodyEndExtras,
1231
1327
  appHtmlSegments,
1232
1328
  errMeta(head),
1329
+ undefined,
1330
+ undefined,
1331
+ statusTitle,
1233
1332
  );
1234
1333
  return compress(html, "text/html; charset=utf-8", req, status);
1235
1334
  } catch (err) {
@@ -47,7 +47,10 @@ function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
47
47
  });
48
48
  }
49
49
 
50
- export function generateRoutesFile(manifest: RouteManifest): void {
50
+ /** `defaultTitle` is what a page with no title of its own is called: app.html's
51
+ * <title> as raw HTML text if it has one, else DEFAULT_TITLE — the same title
52
+ * SSR falls back to. Required, so a caller can't quietly drop app.html's. */
53
+ export function generateRoutesFile(manifest: RouteManifest, defaultTitle: string): void {
51
54
  const lines: string[] = ["// AUTO-GENERATED by bosia build — do not edit\n", ONCE_HELPER];
52
55
 
53
56
  const pages = sortRoutes(manifest.pages);
@@ -109,6 +112,8 @@ export function generateRoutesFile(manifest: RouteManifest): void {
109
112
  lines.push(" },");
110
113
  }
111
114
  lines.push("];\n");
115
+ // Unread on the server, but App.svelte imports it and is rendered there too.
116
+ lines.push(defaultTitleExport(defaultTitle));
112
117
 
113
118
  // serverRoutes
114
119
  lines.push("export const serverRoutes: Array<{");
@@ -169,7 +174,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
169
174
  writeFileSync(".bosia/routes.ts", lines.join("\n"));
170
175
 
171
176
  // Generate client-only routes file (no server imports that could pull in Node builtins)
172
- generateClientRoutesFile(pages, manifest.errorPage);
177
+ generateClientRoutesFile(pages, manifest.errorPage, defaultTitle);
173
178
 
174
179
  const pagePatterns = pages.map((p) => p.pattern).join(", ") || "(none)";
175
180
  console.log(`✅ Routes generated: .bosia/routes.ts`);
@@ -184,6 +189,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
184
189
  function generateClientRoutesFile(
185
190
  pages: RouteManifest["pages"],
186
191
  errorPage: RouteManifest["errorPage"],
192
+ defaultTitle: string,
187
193
  ): void {
188
194
  const lines: string[] = [
189
195
  "// AUTO-GENERATED by bosia build — client-only routes (no server imports)\n",
@@ -238,6 +244,7 @@ function generateClientRoutesFile(
238
244
  lines.push(" },");
239
245
  }
240
246
  lines.push("];\n");
247
+ lines.push(defaultTitleExport(defaultTitle));
241
248
 
242
249
  const ep = errorPage;
243
250
  lines.push(
@@ -264,6 +271,12 @@ const ONCE_HELPER = `function once<T>(load: () => Promise<T>): () => Promise<T>
264
271
  }
265
272
  `;
266
273
 
274
+ /** The title client navigation gives a page that sets none (client/App.svelte),
275
+ * as raw HTML text: the browser decodes its entities. */
276
+ function defaultTitleExport(title: string): string {
277
+ return `export const defaultTitle: string = ${JSON.stringify(title)};\n`;
278
+ }
279
+
267
280
  /** A memoized server-side import thunk for `src/routes/<routePath>`. */
268
281
  function serverImport(routePath: string): string {
269
282
  return `once(() => import(${JSON.stringify(toImportPath(routePath))}))`;
@@ -18,11 +18,6 @@ import type { PageRoute, ApiRoute, RouteManifest, TrailingSlash } from "./types.
18
18
 
19
19
  const ROUTES_DIR = "./src/routes";
20
20
 
21
- /**
22
- * Extract `export const trailingSlash = '...'` from a server module file via
23
- * regex. Static-string read only — runtime expressions return null. Build-time
24
- * scan avoids invoking server modules during the client bundle.
25
- */
26
21
  /**
27
22
  * Read `export const cache` from +page.svelte's `<script module>` at scan time.
28
23
  * Conservative on purpose — the flag lets the renderer skip caching, so a wrong
@@ -31,15 +26,11 @@ const ROUTES_DIR = "./src/routes";
31
26
  * else (dynamic expression, unreadable file) returns `null` so the renderer
32
27
  * imports the module and reads the real value.
33
28
  */
34
- function readPageCache(filePath: string): boolean | null {
35
- try {
36
- const src = readFileSync(filePath, "utf-8");
37
- if (!/export\s+const\s+cache\b/.test(src)) return true;
38
- if (/export\s+const\s+cache\s*(?::[^=]+)?=\s*false\b/.test(src)) return false;
39
- return null;
40
- } catch {
41
- return null;
42
- }
29
+ function pageCache(src: string | null): boolean | null {
30
+ if (src === null) return null;
31
+ if (!/export\s+const\s+cache\b/.test(src)) return true;
32
+ if (/export\s+const\s+cache\s*(?::[^=]+)?=\s*false\b/.test(src)) return false;
33
+ return null;
43
34
  }
44
35
 
45
36
  /** Literal `export const prerender = true` in a server module's source. */
@@ -62,27 +53,31 @@ export function prerenderSkipReason(src: string): string | null {
62
53
  }
63
54
 
64
55
  /** True when the page will really be prerendered — the client fetches its data as a static file. */
65
- function readPrerender(filePath: string): boolean {
66
- try {
67
- const src = readFileSync(filePath, "utf-8");
68
- return wantsPrerender(src) && prerenderSkipReason(src) === null;
69
- } catch {
70
- return false;
71
- }
56
+ function prerenderOf(src: string | null): boolean {
57
+ return src !== null && wantsPrerender(src) && prerenderSkipReason(src) === null;
72
58
  }
73
59
 
74
- function readTrailingSlash(filePath: string): TrailingSlash | null {
60
+ /** A route file's source, or null when it can't be read. */
61
+ function readSource(filePath: string): string | null {
75
62
  try {
76
- const src = readFileSync(filePath, "utf-8");
77
- const m = src.match(
78
- /export\s+const\s+trailingSlash\s*(?::\s*[^=]+)?=\s*["'](never|always|ignore)["']/,
79
- );
80
- return (m?.[1] ?? null) as TrailingSlash | null;
63
+ return readFileSync(filePath, "utf-8");
81
64
  } catch {
82
65
  return null;
83
66
  }
84
67
  }
85
68
 
69
+ /**
70
+ * Extract `export const trailingSlash = '...'` from a server module file via
71
+ * regex. Static-string read only — runtime expressions return null. Build-time
72
+ * scan avoids invoking server modules during the client bundle.
73
+ */
74
+ function trailingSlashOf(src: string | null): TrailingSlash | null {
75
+ const m = src?.match(
76
+ /export\s+const\s+trailingSlash\s*(?::\s*[^=]+)?=\s*["'](never|always|ignore)["']/,
77
+ );
78
+ return (m?.[1] ?? null) as TrailingSlash | null;
79
+ }
80
+
86
81
  export function scanRoutes(): RouteManifest {
87
82
  const pages: PageRoute[] = [];
88
83
  const apis: ApiRoute[] = [];
@@ -126,7 +121,7 @@ export function scanRoutes(): RouteManifest {
126
121
  path: layoutServerPath,
127
122
  depth: currentLayouts.length - 1,
128
123
  });
129
- const ts = readTrailingSlash(join(ROUTES_DIR, layoutServerPath));
124
+ const ts = trailingSlashOf(readSource(join(ROUTES_DIR, layoutServerPath)));
130
125
  if (ts) currentTrailingSlash = ts;
131
126
  }
132
127
  if (items.some((i) => i.isFile() && i.name === "+error.svelte")) {
@@ -153,10 +148,12 @@ export function scanRoutes(): RouteManifest {
153
148
  ? rel("+page.server.ts")
154
149
  : null;
155
150
 
156
- const pageTs = pageServerFile ? readTrailingSlash(join(ROUTES_DIR, pageServerFile)) : null;
151
+ const pageServerSrc = pageServerFile ? readSource(join(ROUTES_DIR, pageServerFile)) : null;
152
+ const pageTs = trailingSlashOf(pageServerSrc);
157
153
  const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
158
154
 
159
155
  const pageFile = rel("+page.svelte");
156
+ const pageSrc = readSource(join(ROUTES_DIR, pageFile));
160
157
  pages.push({
161
158
  pattern: toUrlPath(urlSegments),
162
159
  id: `/${dir}`,
@@ -167,8 +164,8 @@ export function scanRoutes(): RouteManifest {
167
164
  layoutServers: [...currentLayoutServers],
168
165
  errorPages: [...currentErrorPages],
169
166
  trailingSlash: effectiveTs,
170
- cache: readPageCache(join(ROUTES_DIR, pageFile)),
171
- prerender: pageServerFile ? readPrerender(join(ROUTES_DIR, pageServerFile)) : false,
167
+ cache: pageCache(pageSrc),
168
+ prerender: prerenderOf(pageServerSrc),
172
169
  });
173
170
  }
174
171