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.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/ambient.d.ts +3 -0
- package/src/cli/sync.ts +14 -1
- package/src/core/appHtml.ts +52 -0
- package/src/core/build.ts +4 -3
- package/src/core/client/App.svelte +82 -12
- package/src/core/client/TitleProbe.svelte +15 -0
- package/src/core/client/router.svelte.ts +1 -2
- package/src/core/client/titleMemory.svelte.ts +64 -0
- package/src/core/headScan.ts +132 -0
- package/src/core/html.ts +103 -45
- package/src/core/renderer.ts +190 -91
- package/src/core/routeFile.ts +15 -2
- package/src/core/scanner.ts +28 -31
- package/src/core/server.ts +95 -80
- package/src/lib/utils.ts +1 -8
- package/templates/default/package.json +2 -2
- package/templates/demo/package.json +2 -2
- package/templates/shop/package.json +2 -2
- package/templates/shop/src/routes/(private)/dashboard/files/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/products/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/users/+page.svelte +9 -0
- package/templates/shop/src/routes/(public)/+page.svelte +2 -2
- package/templates/store/package.json +2 -2
- package/templates/store/src/routes/(private)/dashboard/files/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/products/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/users/+page.svelte +9 -0
- package/templates/store/src/routes/(public)/+page.svelte +2 -2
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
404
|
-
const
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
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:
|
|
500
|
+
metadata: await metadataData,
|
|
424
501
|
depends: makeDepends(deps),
|
|
425
502
|
platform: getPlatform(),
|
|
426
503
|
setHeaders,
|
|
427
504
|
}),
|
|
428
505
|
LOAD_TIMEOUT,
|
|
429
|
-
`
|
|
506
|
+
`page load (${url.pathname})`,
|
|
430
507
|
)) ?? {};
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
464
|
-
let
|
|
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
|
-
|
|
469
|
-
if (route.pageServer) {
|
|
581
|
+
if (pageP) {
|
|
470
582
|
try {
|
|
471
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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) {
|
package/src/core/routeFile.ts
CHANGED
|
@@ -47,7 +47,10 @@ function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
|
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
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))}))`;
|
package/src/core/scanner.ts
CHANGED
|
@@ -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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
171
|
-
prerender:
|
|
167
|
+
cache: pageCache(pageSrc),
|
|
168
|
+
prerender: prerenderOf(pageServerSrc),
|
|
172
169
|
});
|
|
173
170
|
}
|
|
174
171
|
|