bosia 1.2.0 → 1.2.2
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 +35 -0
- package/src/core/build.ts +5 -4
- package/src/core/client/App.svelte +107 -12
- package/src/core/client/TitleProbe.svelte +15 -0
- package/src/core/client/titleMemory.svelte.ts +64 -0
- package/src/core/headScan.ts +138 -0
- package/src/core/html.ts +23 -144
- package/src/core/prerender.ts +89 -10
- package/src/core/renderer.ts +59 -3
- package/src/core/routeFile.ts +15 -2
- package/src/core/scanner.ts +28 -31
- package/templates/shop/src/routes/(public)/+page.svelte +2 -2
- package/templates/store/src/routes/(public)/+page.svelte +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bosapi and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
package/package.json
CHANGED
package/src/ambient.d.ts
CHANGED
package/src/cli/sync.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { scanRoutes, RouteConflictError } from "../core/scanner.ts";
|
|
2
2
|
import { generateRoutesFile } from "../core/routeFile.ts";
|
|
3
|
+
import { clientDefaultTitle, loadAppHtmlTemplate } from "../core/appHtml.ts";
|
|
4
|
+
import { DEFAULT_TITLE } from "../core/headScan.ts";
|
|
3
5
|
import { generateRouteTypes, ensureRootDirs } from "../core/routeTypes.ts";
|
|
4
6
|
import { loadEnv, classifyEnvVars } from "../core/env.ts";
|
|
5
7
|
import { generateEnvModules } from "../core/envCodegen.ts";
|
|
@@ -16,7 +18,7 @@ export async function runSync() {
|
|
|
16
18
|
console.error(`❌ ${err.message}`);
|
|
17
19
|
process.exit(1);
|
|
18
20
|
}
|
|
19
|
-
generateRoutesFile(manifest);
|
|
21
|
+
generateRoutesFile(manifest, syncDefaultTitle());
|
|
20
22
|
generateRouteTypes(manifest);
|
|
21
23
|
ensureRootDirs();
|
|
22
24
|
generateEnvModules(classifiedEnv);
|
|
@@ -32,3 +34,14 @@ export async function runSync() {
|
|
|
32
34
|
process.exit(1);
|
|
33
35
|
}
|
|
34
36
|
}
|
|
37
|
+
|
|
38
|
+
/** app.html's title, as build passes it. A missing or broken app.html is the
|
|
39
|
+
* build's error to report; sync still writes the routes. */
|
|
40
|
+
function syncDefaultTitle(): string {
|
|
41
|
+
try {
|
|
42
|
+
return clientDefaultTitle(loadAppHtmlTemplate());
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.warn(`⚠️ Could not read the app.html title, using "${DEFAULT_TITLE}":`, err);
|
|
45
|
+
return DEFAULT_TITLE;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/core/appHtml.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { OUT_DIR } from "./paths.ts";
|
|
|
5
5
|
import { readArtifact } from "./artifacts.ts";
|
|
6
6
|
import { rebaseHtmlAttrs } from "./basePath.ts";
|
|
7
7
|
import { currentBase } from "./appBase.ts";
|
|
8
|
+
import { DEFAULT_TITLE, hasTitle, titleText } from "./headScan.ts";
|
|
8
9
|
|
|
9
10
|
// ─── Types ────────────────────────────────────────────────
|
|
10
11
|
|
|
@@ -79,6 +80,40 @@ export function findHeadEnd(headClose: string): number {
|
|
|
79
80
|
return -1;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
type HeadSplit = { before: string; after: string; hasTitle: boolean };
|
|
84
|
+
|
|
85
|
+
// app.html never changes after it is parsed, so split it and check it for a
|
|
86
|
+
// title once per segments object instead of on every render. Kept in memory,
|
|
87
|
+
// not in dist/app-html.json, so it can't go stale against headClose.
|
|
88
|
+
const headSplits = new WeakMap<AppHtmlSegments, HeadSplit>();
|
|
89
|
+
|
|
90
|
+
/** headClose split at the end of the head: `before` still belongs to <head>,
|
|
91
|
+
* `after` runs on to %bosia.body%. Also whether app.html's head sets a title. */
|
|
92
|
+
export function headSplit(segments: AppHtmlSegments): HeadSplit {
|
|
93
|
+
let split = headSplits.get(segments);
|
|
94
|
+
if (!split) {
|
|
95
|
+
const { headOpen, headClose } = segments;
|
|
96
|
+
const i = findHeadEnd(headClose);
|
|
97
|
+
const before = i < 0 ? "" : headClose.slice(0, i);
|
|
98
|
+
const after = i < 0 ? headClose : headClose.slice(i);
|
|
99
|
+
split = { before, after, hasTitle: hasTitle(headOpen) || hasTitle(before) };
|
|
100
|
+
headSplits.set(segments, split);
|
|
101
|
+
}
|
|
102
|
+
return split;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The raw text of app.html's own <title>, or null when it has none. Looks in
|
|
106
|
+
* the same places as headSplit, so it finds the title SSR keeps. */
|
|
107
|
+
export function appHtmlTitle(segments: AppHtmlSegments): string | null {
|
|
108
|
+
return titleText(segments.headOpen) ?? titleText(headSplit(segments).before);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What client navigation calls a page with no title anywhere: app.html's
|
|
112
|
+
* <title>, else "Bosia App" — as SSR does. Raw text; the browser decodes it. */
|
|
113
|
+
export function clientDefaultTitle(segments: AppHtmlSegments): string {
|
|
114
|
+
return appHtmlTitle(segments) ?? DEFAULT_TITLE;
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
function replaceStaticPlaceholders(template: string): string {
|
|
83
118
|
// Replace %bosia.assets% with BOSIA_ASSETS_BASE env var
|
|
84
119
|
const assetsBase = process.env.BOSIA_ASSETS_BASE || "";
|
package/src/core/build.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { currentBase } from "./appBase.ts";
|
|
|
27
27
|
import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
|
|
28
28
|
import { loadBosiaConfig, loadPlugins } from "./config.ts";
|
|
29
29
|
import type { BuildContext, RuntimeTarget } from "./types/plugin.ts";
|
|
30
|
-
import { loadAppHtmlTemplate, writeAppHtmlSegments } from "./appHtml.ts";
|
|
30
|
+
import { clientDefaultTitle, loadAppHtmlTemplate, writeAppHtmlSegments } from "./appHtml.ts";
|
|
31
31
|
import {
|
|
32
32
|
generateArtifactsModule,
|
|
33
33
|
generateWorkersRuntime,
|
|
@@ -189,8 +189,9 @@ for (const p of userPlugins) {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// 2. Generate .bosia/routes.ts (single file replaces all old code generators)
|
|
193
|
-
|
|
192
|
+
// 2. Generate .bosia/routes.ts (single file replaces all old code generators).
|
|
193
|
+
// The client names a page with no title after app.html, as SSR does.
|
|
194
|
+
generateRoutesFile(manifest, clientDefaultTitle(appHtml));
|
|
194
195
|
|
|
195
196
|
// 2b. Generate .bosia/types/src/routes/**/$types.d.ts for IDE type inference
|
|
196
197
|
generateRouteTypes(manifest);
|
|
@@ -399,7 +400,7 @@ await bundleRuntimeUserFiles(process.cwd());
|
|
|
399
400
|
const devBuild = process.env.BOSIA_DEV_BUILD === "1";
|
|
400
401
|
|
|
401
402
|
// 9. Prerender static routes
|
|
402
|
-
if (!devBuild) await prerenderStaticRoutes(manifest);
|
|
403
|
+
if (!devBuild) await prerenderStaticRoutes(manifest, target);
|
|
403
404
|
|
|
404
405
|
// 10. Generate static site output (HTML + client assets + public → dist/static/)
|
|
405
406
|
if (!devBuild) generateStaticSite();
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
import { tick } from "svelte";
|
|
3
3
|
import { router, scrollToHash } from "./router.svelte.ts";
|
|
4
4
|
import { findMatch } from "../matcher.ts";
|
|
5
|
-
import { clientRoutes } from "bosia:routes";
|
|
5
|
+
import { clientRoutes, defaultTitle } from "bosia:routes";
|
|
6
6
|
import { consumePrefetch, prefetchCache, dataUrl, readDataResponse } from "./prefetch.ts";
|
|
7
7
|
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
8
8
|
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
9
9
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
10
10
|
import { fireAfterNavigate, type Navigation } from "./navListeners.ts";
|
|
11
11
|
import { drainNavResolvers } from "./navigation.ts";
|
|
12
|
+
import { collapseTitle, errorTitle } from "../headScan.ts";
|
|
13
|
+
import { chainTitles, takeTitleWrite, titleNav } from "./titleMemory.svelte.ts";
|
|
14
|
+
import TitleProbe from "./TitleProbe.svelte";
|
|
12
15
|
|
|
13
16
|
let {
|
|
14
17
|
ssrMode = false,
|
|
@@ -39,6 +42,43 @@
|
|
|
39
42
|
let pageInstance = $state<any>(null);
|
|
40
43
|
if (!ssrMode) router.getPageSnapshot = () => pageInstance?.snapshot;
|
|
41
44
|
|
|
45
|
+
// The last title navigation (or SSR's metadata()) wrote, as document.title
|
|
46
|
+
// reads it. Same page, same title still → no component took it over, so
|
|
47
|
+
// navigation may replace it again.
|
|
48
|
+
let navTitle: string | null = null;
|
|
49
|
+
|
|
50
|
+
// Build-time titles are raw HTML text; a <textarea> decodes every entity the
|
|
51
|
+
// way the browser did on a hard load. Trusted Types block the innerHTML
|
|
52
|
+
// write, so the raw text is the fallback there.
|
|
53
|
+
const decoded = new Map<string, string>();
|
|
54
|
+
function decodeTitle(raw: string): string {
|
|
55
|
+
let text = decoded.get(raw);
|
|
56
|
+
if (text === undefined) {
|
|
57
|
+
try {
|
|
58
|
+
const t = document.createElement("textarea");
|
|
59
|
+
t.innerHTML = raw;
|
|
60
|
+
text = collapseTitle(t.value);
|
|
61
|
+
} catch {
|
|
62
|
+
text = collapseTitle(raw);
|
|
63
|
+
}
|
|
64
|
+
decoded.set(raw, text);
|
|
65
|
+
}
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The title a page with none of its own shows under the first `depth`
|
|
70
|
+
* layouts: the deepest one's that wrote one (TitleProbe), else app.html's. */
|
|
71
|
+
function baseTitle(depth: number): string {
|
|
72
|
+
return (depth > 0 ? chainTitles[depth - 1] : null) ?? decodeTitle(defaultTitle);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How many leading layouts two routes share, by file path. */
|
|
76
|
+
function sharedDepth(a: string[], b: string[]): number {
|
|
77
|
+
let s = 0;
|
|
78
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++;
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
// Call the landed-on page's snapshot.restore() once it's mounted (post-tick).
|
|
43
83
|
function settleSnapshot() {
|
|
44
84
|
const snap = router.pendingSnapshot;
|
|
@@ -104,6 +144,15 @@
|
|
|
104
144
|
settleSnapshot();
|
|
105
145
|
}
|
|
106
146
|
|
|
147
|
+
// Each flush a navigation starts begins here, before any head effect: what
|
|
148
|
+
// was written since — navigation's own title, an afterNavigate listener's,
|
|
149
|
+
// the last page's — is no layout's, so the probes must not take it.
|
|
150
|
+
$effect.pre(() => {
|
|
151
|
+
if (ssrMode) return;
|
|
152
|
+
titleNav.seq;
|
|
153
|
+
takeTitleWrite();
|
|
154
|
+
});
|
|
155
|
+
|
|
107
156
|
$effect(() => {
|
|
108
157
|
if (ssrMode) return;
|
|
109
158
|
|
|
@@ -124,6 +173,11 @@
|
|
|
124
173
|
firstNav = false;
|
|
125
174
|
if (isFirst) {
|
|
126
175
|
currentLayoutPaths = (match.route as any).layoutPaths ?? [];
|
|
176
|
+
// The probes ran during hydration, before this effect. A title other than
|
|
177
|
+
// metadata()'s or the layouts' was a component's, so navigation keeps it.
|
|
178
|
+
const ssrTitle: string | undefined = (window as any).__BOSIA_TITLE__;
|
|
179
|
+
const base = baseTitle(currentLayoutPaths.length);
|
|
180
|
+
navTitle = ssrTitle ? collapseTitle(ssrTitle) : document.title === base ? base : null;
|
|
127
181
|
lastSettledPath = pathname;
|
|
128
182
|
lastNavKey = navKey;
|
|
129
183
|
lastTick = currentTick;
|
|
@@ -162,13 +216,7 @@
|
|
|
162
216
|
const destLayoutPaths = ((match.route as any).layoutPaths as string[]) ?? [];
|
|
163
217
|
const loadingImport = (match.route as any).loading as (() => Promise<any>) | null;
|
|
164
218
|
if (loadingImport && pathname !== lastSettledPath) {
|
|
165
|
-
|
|
166
|
-
while (
|
|
167
|
-
s < currentLayoutPaths.length &&
|
|
168
|
-
s < destLayoutPaths.length &&
|
|
169
|
-
currentLayoutPaths[s] === destLayoutPaths[s]
|
|
170
|
-
)
|
|
171
|
-
s++;
|
|
219
|
+
const s = sharedDepth(currentLayoutPaths, destLayoutPaths);
|
|
172
220
|
loadingImport().then((m) => {
|
|
173
221
|
if (cancelled) return; // superseded nav
|
|
174
222
|
loadingDepth = s;
|
|
@@ -259,9 +307,14 @@
|
|
|
259
307
|
// Tear down the loading skeleton and record identity for the next nav.
|
|
260
308
|
// Superseded navs have cancelled === true and never reach here, so they
|
|
261
309
|
// can't clobber a newer nav's loader.
|
|
310
|
+
// The skeleton and the error page render in their own {#if} branch, so
|
|
311
|
+
// leaving either remounts the page. Read before both are cleared below.
|
|
312
|
+
// The skeleton shows only once its chunk loaded (template's condition).
|
|
313
|
+
const skeletonShown = showLoading && !!LoadingComponent;
|
|
314
|
+
const pageShown = !skeletonShown && !appState.errorComponent;
|
|
315
|
+
const errorShown = skeletonShown ? null : appState.errorComponent;
|
|
262
316
|
showLoading = false;
|
|
263
317
|
LoadingComponent = null;
|
|
264
|
-
currentLayoutPaths = destLayoutPaths;
|
|
265
318
|
lastSettledPath = pathname;
|
|
266
319
|
lastNavKey = navKey;
|
|
267
320
|
navDoneTimer = setTimeout(() => {
|
|
@@ -300,7 +353,22 @@
|
|
|
300
353
|
...match.route.layouts.slice(0, K).map((l: any) => l()),
|
|
301
354
|
]);
|
|
302
355
|
if (cancelled) return;
|
|
356
|
+
// Error page to error page: the layouts both share stay mounted and
|
|
357
|
+
// never write their title again. Same error component under all of
|
|
358
|
+
// them too: nothing remounts, so a <title> it wrote stays — keep it.
|
|
359
|
+
const kept = errorShown
|
|
360
|
+
? sharedDepth(currentLayoutPaths, destLayoutPaths.slice(0, K))
|
|
361
|
+
: 0;
|
|
362
|
+
const prev = appState.errorProps?.error;
|
|
363
|
+
const keepTitle =
|
|
364
|
+
errorShown === errMod.default &&
|
|
365
|
+
kept === K &&
|
|
366
|
+
appState.errorDepth === K &&
|
|
367
|
+
!!prev &&
|
|
368
|
+
document.title !== collapseTitle(errorTitle(prev.status, prev.message));
|
|
303
369
|
layoutComponents = layoutModsForError.map((m: any) => m.default);
|
|
370
|
+
currentLayoutPaths = destLayoutPaths.slice(0, K);
|
|
371
|
+
titleNav.seq++; // the probes read again as this flushes
|
|
304
372
|
const newLayoutData: Record<string, any>[] = [];
|
|
305
373
|
for (let i = 0; i < K; i++) newLayoutData.push({});
|
|
306
374
|
appState.layoutData = newLayoutData;
|
|
@@ -309,6 +377,15 @@
|
|
|
309
377
|
appState.errorComponent = errMod.default;
|
|
310
378
|
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
311
379
|
appState.errorDepth = K;
|
|
380
|
+
// What a hard load shows (renderer.ts): a layout's title, else the
|
|
381
|
+
// status title. Set before Svelte flushes, so a freshly mounted
|
|
382
|
+
// <title> in +error.svelte or a layout still wins.
|
|
383
|
+
document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
|
|
384
|
+
if (!keepTitle) {
|
|
385
|
+
document.title =
|
|
386
|
+
(kept > 0 ? chainTitles[kept - 1] : null) ?? errorTitle(errStatus, errMessage);
|
|
387
|
+
navTitle = document.title;
|
|
388
|
+
}
|
|
312
389
|
settleScroll();
|
|
313
390
|
settle({ url, params: match.params });
|
|
314
391
|
} catch {
|
|
@@ -316,6 +393,11 @@
|
|
|
316
393
|
}
|
|
317
394
|
return;
|
|
318
395
|
}
|
|
396
|
+
// Layout paths are recorded only once they render: a redirect above
|
|
397
|
+
// leaves the mounted ones in place.
|
|
398
|
+
const shared = sharedDepth(currentLayoutPaths, destLayoutPaths);
|
|
399
|
+
currentLayoutPaths = destLayoutPaths;
|
|
400
|
+
const pageChanged = !pageShown || PageComponent !== pageMod.default;
|
|
319
401
|
PageComponent = pageMod.default;
|
|
320
402
|
layoutComponents = layoutMods.map((m: any) => m.default);
|
|
321
403
|
|
|
@@ -408,9 +490,19 @@
|
|
|
408
490
|
if (result || !match.route.hasServerData) {
|
|
409
491
|
const md = result?.metadata;
|
|
410
492
|
document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
|
|
411
|
-
//
|
|
412
|
-
// the
|
|
413
|
-
|
|
493
|
+
// With no metadata() title, a page gets the one a hard load would give
|
|
494
|
+
// it: the layouts' (baseTitle), else app.html's <title>, else "Bosia
|
|
495
|
+
// App". A layout that stays mounted never writes its title again, so it
|
|
496
|
+
// is put back here. Set before Svelte flushes, so a freshly mounted
|
|
497
|
+
// <svelte:head><title> still wins. On the same page (invalidate, a form
|
|
498
|
+
// action, ?page=2) it stays once a component like <Seo> took it over.
|
|
499
|
+
if (md?.title) {
|
|
500
|
+
document.title = md.title;
|
|
501
|
+
navTitle = document.title;
|
|
502
|
+
} else if (pageChanged || document.title === navTitle) {
|
|
503
|
+
document.title = baseTitle(shared);
|
|
504
|
+
navTitle = document.title;
|
|
505
|
+
}
|
|
414
506
|
if (md?.lang) document.documentElement.lang = md.lang;
|
|
415
507
|
const emit = (tag: "meta" | "link", attrs: Record<string, string | undefined>) => {
|
|
416
508
|
const el = document.createElement(tag);
|
|
@@ -424,6 +516,7 @@
|
|
|
424
516
|
for (const l of md?.link ?? [])
|
|
425
517
|
emit("link", { rel: l.rel, href: l.href, hreflang: l.hreflang });
|
|
426
518
|
}
|
|
519
|
+
titleNav.seq++; // the probes read again as this flushes
|
|
427
520
|
|
|
428
521
|
settle({ url, params: match.params });
|
|
429
522
|
});
|
|
@@ -480,10 +573,12 @@
|
|
|
480
573
|
|
|
481
574
|
{#if index < leafDepth - 1}
|
|
482
575
|
<Layout {data} {params}>
|
|
576
|
+
<TitleProbe depth={index} layout={Layout} />
|
|
483
577
|
{@render renderLayout(index + 1, leafDepth)}
|
|
484
578
|
</Layout>
|
|
485
579
|
{:else}
|
|
486
580
|
<Layout {data} {params}>
|
|
581
|
+
<TitleProbe depth={index} layout={Layout} />
|
|
487
582
|
{#if ErrorComponent}
|
|
488
583
|
<ErrorComponent {...errorProps ?? {}} {params} />
|
|
489
584
|
{:else if PageComponent}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { chainTitles, layoutTitles, takeTitleWrite, titleNav } from "./titleMemory.svelte.ts";
|
|
3
|
+
|
|
4
|
+
// Sits after layout `depth`, before what it wraps, and takes the title that
|
|
5
|
+
// layout wrote. `layout` is the layout component, the key it is remembered by.
|
|
6
|
+
let { depth, layout }: { depth: number; layout: unknown } = $props();
|
|
7
|
+
|
|
8
|
+
$effect(() => {
|
|
9
|
+
titleNav.seq; // read again on every navigation
|
|
10
|
+
const w = takeTitleWrite();
|
|
11
|
+
if (w !== null) layoutTitles.set(layout, w);
|
|
12
|
+
chainTitles[depth] =
|
|
13
|
+
layoutTitles.get(layout) ?? (depth > 0 ? (chainTitles[depth - 1] ?? null) : null);
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The title each layout depth shows, read at run time. Svelte writes a
|
|
2
|
+
// <svelte:head><title> only on mount or when its value changes, so a layout
|
|
3
|
+
// that stays mounted through a navigation never writes it again. A
|
|
4
|
+
// <TitleProbe> after each layout (App.svelte) notes the title written since
|
|
5
|
+
// the probe above it — that layout's own, a <Seo> in it, a dynamic one —
|
|
6
|
+
// so navigation can put it back.
|
|
7
|
+
|
|
8
|
+
/** Bumped once per navigation, so every probe reads again in that flush. */
|
|
9
|
+
export const titleNav = $state({ seq: 0 });
|
|
10
|
+
|
|
11
|
+
/** Per layout depth: the title the layouts down to it show, or null for none. */
|
|
12
|
+
export const chainTitles: (string | null)[] = [];
|
|
13
|
+
|
|
14
|
+
/** Per layout component: the title it wrote last. Kept here, not in the probe,
|
|
15
|
+
* since a layout that wraps its children in {#key} remounts the probe but
|
|
16
|
+
* doesn't write its title again. */
|
|
17
|
+
export const layoutTitles = new Map<unknown, string>();
|
|
18
|
+
|
|
19
|
+
// One record per page, on window, so a second copy of this module (a duplicate
|
|
20
|
+
// chunk, a dev reload) shares the patch instead of finding it taken.
|
|
21
|
+
type TitleWrites = { written: string | null; gen: number; patched: boolean };
|
|
22
|
+
const KEY = "__bosiaTitleWrites";
|
|
23
|
+
const store: TitleWrites =
|
|
24
|
+
typeof window === "undefined"
|
|
25
|
+
? { written: null, gen: 0, patched: false }
|
|
26
|
+
: ((window as any)[KEY] ??= { written: null, gen: 0, patched: false });
|
|
27
|
+
|
|
28
|
+
/** The last title written since the previous call, or null. */
|
|
29
|
+
export function takeTitleWrite(): string | null {
|
|
30
|
+
const w = store.written;
|
|
31
|
+
store.written = null;
|
|
32
|
+
return w;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Every write to document.title, from Svelte or anyone, passes through here.
|
|
36
|
+
// The probes run in tree order, each right after its layout's head effects,
|
|
37
|
+
// so the write each one takes is its layout's. A write is kept only until the
|
|
38
|
+
// next microtask: Svelte flushes in one go, so a probe sees the writes of its
|
|
39
|
+
// own flush, never a page's from an earlier one.
|
|
40
|
+
if (typeof document !== "undefined" && !store.patched) {
|
|
41
|
+
const native = Object.getOwnPropertyDescriptor(Document.prototype, "title");
|
|
42
|
+
if (native?.get && native.set && !Object.prototype.hasOwnProperty.call(document, "title")) {
|
|
43
|
+
const { get, set } = native;
|
|
44
|
+
Object.defineProperty(document, "title", {
|
|
45
|
+
configurable: true,
|
|
46
|
+
get() {
|
|
47
|
+
return get.call(this);
|
|
48
|
+
},
|
|
49
|
+
set(v: string) {
|
|
50
|
+
set.call(this, v);
|
|
51
|
+
store.written = get.call(this);
|
|
52
|
+
const gen = ++store.gen;
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
if (store.gen === gen) store.written = null;
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
store.patched = true;
|
|
59
|
+
} else if (process.env.NODE_ENV !== "production") {
|
|
60
|
+
console.warn(
|
|
61
|
+
"[bosia] document.title is already overridden, so client navigation can't put a layout's title back.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Finding the <title> in a chunk of head markup. Pure string code with no
|
|
2
|
+
// imports, so build.ts and appHtml.ts can use it too — html.ts reads the dist
|
|
3
|
+
// manifest the moment it loads, which neither of them can afford.
|
|
4
|
+
|
|
5
|
+
/** The title a page gets when nothing else sets one. */
|
|
6
|
+
export const DEFAULT_TITLE = "Bosia App";
|
|
7
|
+
|
|
8
|
+
/** The title an error page gets when its components set none: `404 — Not Found`.
|
|
9
|
+
* SSR and client navigation both use it, so the two never drift apart. */
|
|
10
|
+
export function errorTitle(status: number, message: string): string {
|
|
11
|
+
return `${status} — ${message}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Whether a chunk of head markup already sets the document title. Matches a
|
|
15
|
+
* <title> with attributes too. Ignores one in a comment, an attribute value, a
|
|
16
|
+
* script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
|
|
17
|
+
* (which only labels the icon). */
|
|
18
|
+
export function hasTitle(html: string): boolean {
|
|
19
|
+
return titleTagEnd(html) !== -1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The raw text of the <title> that hasTitle finds, or null when there is none.
|
|
23
|
+
* Whitespace is collapsed and trimmed as document.title does; entities are left
|
|
24
|
+
* for the browser to decode. An unclosed <title> runs to the end, as in a browser. */
|
|
25
|
+
export function titleText(html: string): string | null {
|
|
26
|
+
const start = titleTagEnd(html);
|
|
27
|
+
if (start === -1) return null;
|
|
28
|
+
const m = /<\/title(?=[\t\n\f\r />])/i.exec(html.slice(start));
|
|
29
|
+
const raw = m ? html.slice(start, start + m.index) : html.slice(start);
|
|
30
|
+
return collapseTitle(raw);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A title as document.title reads it back: HTML whitespace runs collapsed to
|
|
34
|
+
* one space, and trimmed. */
|
|
35
|
+
export function collapseTitle(text: string): string {
|
|
36
|
+
return text.replace(/[\t\n\f\r ]+/g, " ").trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Index just past the `>` of the first real <title> tag, or -1. */
|
|
40
|
+
function titleTagEnd(html: string): number {
|
|
41
|
+
// A single forward scan, not a regex: a lazy `<script…</script>` match retries
|
|
42
|
+
// from every unclosed `<script` and goes quadratic on hostile {@html} output.
|
|
43
|
+
// Each tag is skipped whole, quotes included, so nothing inside an attribute
|
|
44
|
+
// looks like a tag. This is a heuristic, not a full HTML tokenizer.
|
|
45
|
+
let unclosed: Set<string> | undefined;
|
|
46
|
+
let i = 0;
|
|
47
|
+
while ((i = html.indexOf("<", i)) !== -1) {
|
|
48
|
+
if (html.startsWith("<!--", i)) {
|
|
49
|
+
i = commentEnd(html, i);
|
|
50
|
+
if (i === -1) return -1; // unclosed comment runs to the end
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!isAsciiAlpha(html.charCodeAt(i + 1))) {
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const nameEnd = tagNameEnd(html, i + 1);
|
|
58
|
+
const tagEnd = openTagEnd(html, nameEnd);
|
|
59
|
+
if (tagEnd === -1) return -1; // the rest is inside an unfinished tag
|
|
60
|
+
const name = nameEnd - i - 1 <= MAX_NAME ? html.slice(i + 1, nameEnd).toLowerCase() : "";
|
|
61
|
+
if (name === "title") return tagEnd;
|
|
62
|
+
i = tagEnd;
|
|
63
|
+
const close = SKIPPED_TAGS.get(name);
|
|
64
|
+
// Only foreign content like <svg/> honors the self-closing slash.
|
|
65
|
+
const selfClosed = name === "svg" && html.charCodeAt(tagEnd - 2) === 47;
|
|
66
|
+
if (!close || selfClosed || unclosed?.has(name)) continue;
|
|
67
|
+
// An opener with no closer is read as a plain tag, and remembered, so
|
|
68
|
+
// later openers of that tag don't search to the end again.
|
|
69
|
+
close.lastIndex = tagEnd;
|
|
70
|
+
const gt = close.exec(html) ? html.indexOf(">", close.lastIndex) : -1;
|
|
71
|
+
if (gt === -1) (unclosed ??= new Set()).add(name);
|
|
72
|
+
else i = gt + 1;
|
|
73
|
+
}
|
|
74
|
+
return -1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `</tag` followed by whitespace, `/` or `>`; the caller finds the `>` itself,
|
|
78
|
+
* since a `[^>]*>` here would rescan to the end for every candidate. */
|
|
79
|
+
const SKIPPED_TAGS = new Map(
|
|
80
|
+
["script", "style", "noscript", "template", "svg"].map((t) => [
|
|
81
|
+
t,
|
|
82
|
+
new RegExp(`</${t}(?=[\\t\\n\\f\\r />])`, "gi"),
|
|
83
|
+
]),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/** Longest tag name the scan needs to tell apart; longer names are never sliced. */
|
|
87
|
+
const MAX_NAME = Math.max("title".length, ...[...SKIPPED_TAGS.keys()].map((t) => t.length));
|
|
88
|
+
|
|
89
|
+
/** Index just past the comment opening at `start`, or -1 when it never closes.
|
|
90
|
+
* `<!-->` and `<!--->` are complete empty comments. */
|
|
91
|
+
function commentEnd(html: string, start: number): number {
|
|
92
|
+
if (html.startsWith(">", start + 4)) return start + 5;
|
|
93
|
+
if (html.startsWith("->", start + 4)) return start + 6;
|
|
94
|
+
const end = html.indexOf("-->", start + 4);
|
|
95
|
+
return end === -1 ? -1 : end + 3;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** End of the tag name starting at `start`: the first whitespace, `/` or `>`. */
|
|
99
|
+
function tagNameEnd(html: string, start: number): number {
|
|
100
|
+
let j = start;
|
|
101
|
+
while (j < html.length) {
|
|
102
|
+
const c = html.charCodeAt(j);
|
|
103
|
+
if (isHtmlSpace(c) || c === 47 || c === 62) break;
|
|
104
|
+
j++;
|
|
105
|
+
}
|
|
106
|
+
return j;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Index just past the `>` closing a start tag, skipping quoted attribute
|
|
110
|
+
* values, or -1 when the tag never closes. */
|
|
111
|
+
function openTagEnd(html: string, from: number): number {
|
|
112
|
+
for (let j = from; j < html.length; j++) {
|
|
113
|
+
const c = html.charCodeAt(j);
|
|
114
|
+
if (c === 62) return j + 1;
|
|
115
|
+
if (c !== 61) continue; // `=` starts a value
|
|
116
|
+
do j++;
|
|
117
|
+
while (j < html.length && isHtmlSpace(html.charCodeAt(j)));
|
|
118
|
+
const q = html.charCodeAt(j);
|
|
119
|
+
if (q === 34 || q === 39) {
|
|
120
|
+
j = html.indexOf(html[j]!, j + 1);
|
|
121
|
+
if (j === -1) return -1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// Unquoted: runs to whitespace or `>`, quotes and `=` included.
|
|
125
|
+
while (j < html.length && !isHtmlSpace(html.charCodeAt(j)) && html.charCodeAt(j) !== 62) j++;
|
|
126
|
+
j--;
|
|
127
|
+
}
|
|
128
|
+
return -1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** HTML whitespace: tab, LF, FF, CR, space. Not \s, which also matches NBSP. */
|
|
132
|
+
function isHtmlSpace(c: number): boolean {
|
|
133
|
+
return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isAsciiAlpha(c: number): boolean {
|
|
137
|
+
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
138
|
+
}
|
package/src/core/html.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { nonceAttr } from "./csp.ts";
|
|
|
5
5
|
import { rebaseHtmlAttrs } from "./basePath.ts";
|
|
6
6
|
import { currentBase } from "./appBase.ts";
|
|
7
7
|
import type { AppHtmlSegments } from "./appHtml.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { headSplit, interpolateSegment } from "./appHtml.ts";
|
|
9
9
|
import type { Metadata } from "./hooks.ts";
|
|
10
|
+
import { DEFAULT_TITLE, hasTitle } from "./headScan.ts";
|
|
10
11
|
|
|
11
12
|
// Workers compresses any body sent with a Content-Encoding header — again, if it
|
|
12
13
|
// already is — unless told the bytes are final. Bun ignores the key.
|
|
@@ -273,14 +274,8 @@ export function buildHtml(
|
|
|
273
274
|
? `\n <script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
|
|
274
275
|
: "";
|
|
275
276
|
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
const depsScript =
|
|
279
|
-
pageDeps !== null || layoutDeps !== null
|
|
280
|
-
? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
|
|
281
|
-
: "";
|
|
282
|
-
|
|
283
|
-
const sysScript = ssrFlag || depsScript ? `\n <script${n}>${ssrFlag}${depsScript}</script>` : "";
|
|
277
|
+
const sys = sysScriptBody(ssr, metadata?.title, pageDeps, layoutDeps);
|
|
278
|
+
const sysScript = sys ? `\n <script${n}>${sys}</script>` : "";
|
|
284
279
|
|
|
285
280
|
const dataIslands = csr
|
|
286
281
|
? `\n <script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>` +
|
|
@@ -436,130 +431,6 @@ function closeHead(segments: AppHtmlSegments, late: string, nonce?: string): str
|
|
|
436
431
|
);
|
|
437
432
|
}
|
|
438
433
|
|
|
439
|
-
type HeadSplit = { before: string; after: string; hasTitle: boolean };
|
|
440
|
-
|
|
441
|
-
// app.html never changes after it is parsed, so split it and check it for a
|
|
442
|
-
// title once per segments object instead of on every render. Kept in memory,
|
|
443
|
-
// not in dist/app-html.json, so it can't go stale against headClose.
|
|
444
|
-
const headSplits = new WeakMap<AppHtmlSegments, HeadSplit>();
|
|
445
|
-
|
|
446
|
-
function headSplit(segments: AppHtmlSegments): HeadSplit {
|
|
447
|
-
let split = headSplits.get(segments);
|
|
448
|
-
if (!split) {
|
|
449
|
-
const { headOpen, headClose } = segments;
|
|
450
|
-
const i = findHeadEnd(headClose);
|
|
451
|
-
const before = i < 0 ? "" : headClose.slice(0, i);
|
|
452
|
-
const after = i < 0 ? headClose : headClose.slice(i);
|
|
453
|
-
split = { before, after, hasTitle: hasTitle(headOpen) || hasTitle(before) };
|
|
454
|
-
headSplits.set(segments, split);
|
|
455
|
-
}
|
|
456
|
-
return split;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** Whether a chunk of head markup already sets the document title. Matches a
|
|
460
|
-
* <title> with attributes too. Ignores one in a comment, an attribute value, a
|
|
461
|
-
* script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
|
|
462
|
-
* (which only labels the icon). */
|
|
463
|
-
export function hasTitle(html: string): boolean {
|
|
464
|
-
// A single forward scan, not a regex: a lazy `<script…</script>` match retries
|
|
465
|
-
// from every unclosed `<script` and goes quadratic on hostile {@html} output.
|
|
466
|
-
// Each tag is skipped whole, quotes included, so nothing inside an attribute
|
|
467
|
-
// looks like a tag. This is a heuristic, not a full HTML tokenizer.
|
|
468
|
-
let unclosed: Set<string> | undefined;
|
|
469
|
-
let i = 0;
|
|
470
|
-
while ((i = html.indexOf("<", i)) !== -1) {
|
|
471
|
-
if (html.startsWith("<!--", i)) {
|
|
472
|
-
i = commentEnd(html, i);
|
|
473
|
-
if (i === -1) return false; // unclosed comment runs to the end
|
|
474
|
-
continue;
|
|
475
|
-
}
|
|
476
|
-
if (!isAsciiAlpha(html.charCodeAt(i + 1))) {
|
|
477
|
-
i++;
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
const nameEnd = tagNameEnd(html, i + 1);
|
|
481
|
-
const tagEnd = openTagEnd(html, nameEnd);
|
|
482
|
-
if (tagEnd === -1) return false; // the rest is inside an unfinished tag
|
|
483
|
-
const name = nameEnd - i - 1 <= MAX_NAME ? html.slice(i + 1, nameEnd).toLowerCase() : "";
|
|
484
|
-
if (name === "title") return true;
|
|
485
|
-
i = tagEnd;
|
|
486
|
-
const close = SKIPPED_TAGS.get(name);
|
|
487
|
-
// Only foreign content like <svg/> honors the self-closing slash.
|
|
488
|
-
const selfClosed = name === "svg" && html.charCodeAt(tagEnd - 2) === 47;
|
|
489
|
-
if (!close || selfClosed || unclosed?.has(name)) continue;
|
|
490
|
-
// An opener with no closer is read as a plain tag, and remembered, so
|
|
491
|
-
// later openers of that tag don't search to the end again.
|
|
492
|
-
close.lastIndex = tagEnd;
|
|
493
|
-
const gt = close.exec(html) ? html.indexOf(">", close.lastIndex) : -1;
|
|
494
|
-
if (gt === -1) (unclosed ??= new Set()).add(name);
|
|
495
|
-
else i = gt + 1;
|
|
496
|
-
}
|
|
497
|
-
return false;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/** `</tag` followed by whitespace, `/` or `>`; the caller finds the `>` itself,
|
|
501
|
-
* since a `[^>]*>` here would rescan to the end for every candidate. */
|
|
502
|
-
const SKIPPED_TAGS = new Map(
|
|
503
|
-
["script", "style", "noscript", "template", "svg"].map((t) => [
|
|
504
|
-
t,
|
|
505
|
-
new RegExp(`</${t}(?=[\\t\\n\\f\\r />])`, "gi"),
|
|
506
|
-
]),
|
|
507
|
-
);
|
|
508
|
-
|
|
509
|
-
/** Longest tag name hasTitle needs to tell apart; longer names are never sliced. */
|
|
510
|
-
const MAX_NAME = Math.max("title".length, ...[...SKIPPED_TAGS.keys()].map((t) => t.length));
|
|
511
|
-
|
|
512
|
-
/** Index just past the comment opening at `start`, or -1 when it never closes.
|
|
513
|
-
* `<!-->` and `<!--->` are complete empty comments. */
|
|
514
|
-
function commentEnd(html: string, start: number): number {
|
|
515
|
-
if (html.startsWith(">", start + 4)) return start + 5;
|
|
516
|
-
if (html.startsWith("->", start + 4)) return start + 6;
|
|
517
|
-
const end = html.indexOf("-->", start + 4);
|
|
518
|
-
return end === -1 ? -1 : end + 3;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/** End of the tag name starting at `start`: the first whitespace, `/` or `>`. */
|
|
522
|
-
function tagNameEnd(html: string, start: number): number {
|
|
523
|
-
let j = start;
|
|
524
|
-
while (j < html.length) {
|
|
525
|
-
const c = html.charCodeAt(j);
|
|
526
|
-
if (isHtmlSpace(c) || c === 47 || c === 62) break;
|
|
527
|
-
j++;
|
|
528
|
-
}
|
|
529
|
-
return j;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/** Index just past the `>` closing a start tag, skipping quoted attribute
|
|
533
|
-
* values, or -1 when the tag never closes. */
|
|
534
|
-
function openTagEnd(html: string, from: number): number {
|
|
535
|
-
for (let j = from; j < html.length; j++) {
|
|
536
|
-
const c = html.charCodeAt(j);
|
|
537
|
-
if (c === 62) return j + 1;
|
|
538
|
-
if (c !== 61) continue; // `=` starts a value
|
|
539
|
-
do j++;
|
|
540
|
-
while (j < html.length && isHtmlSpace(html.charCodeAt(j)));
|
|
541
|
-
const q = html.charCodeAt(j);
|
|
542
|
-
if (q === 34 || q === 39) {
|
|
543
|
-
j = html.indexOf(html[j]!, j + 1);
|
|
544
|
-
if (j === -1) return -1;
|
|
545
|
-
continue;
|
|
546
|
-
}
|
|
547
|
-
// Unquoted: runs to whitespace or `>`, quotes and `=` included.
|
|
548
|
-
while (j < html.length && !isHtmlSpace(html.charCodeAt(j)) && html.charCodeAt(j) !== 62) j++;
|
|
549
|
-
j--;
|
|
550
|
-
}
|
|
551
|
-
return -1;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/** HTML whitespace: tab, LF, FF, CR, space. Not \s, which also matches NBSP. */
|
|
555
|
-
function isHtmlSpace(c: number): boolean {
|
|
556
|
-
return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
function isAsciiAlpha(c: number): boolean {
|
|
560
|
-
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
434
|
/** Plugin `head` fragments, one per line, empty ones skipped. */
|
|
564
435
|
function headExtraTags(headExtras?: string[]): string {
|
|
565
436
|
let out = "";
|
|
@@ -569,9 +440,6 @@ function headExtraTags(headExtras?: string[]): string {
|
|
|
569
440
|
return out;
|
|
570
441
|
}
|
|
571
442
|
|
|
572
|
-
/** The title a page gets when nothing else sets one. */
|
|
573
|
-
const DEFAULT_TITLE = "Bosia App";
|
|
574
|
-
|
|
575
443
|
/** `<title>{text}</title>` unless app.html or one of the head parts already
|
|
576
444
|
* has a title. Shared by buildHtml and buildMetadataChunk so both paths make
|
|
577
445
|
* the same call; each passes every part it writes into <head> (metadata,
|
|
@@ -645,6 +513,22 @@ export function escapeAttr(s: string): string {
|
|
|
645
513
|
.replace(/>/g, ">");
|
|
646
514
|
}
|
|
647
515
|
|
|
516
|
+
/** The window flags hydration reads, shared by buildHtml and buildHtmlTail.
|
|
517
|
+
* `__BOSIA_TITLE__` is the title metadata() gave: navigation may replace it,
|
|
518
|
+
* but not a title a component set after it (client/App.svelte). */
|
|
519
|
+
function sysScriptBody(
|
|
520
|
+
ssr: boolean,
|
|
521
|
+
title: string | undefined,
|
|
522
|
+
pageDeps: any,
|
|
523
|
+
layoutDeps: any[] | null,
|
|
524
|
+
): string {
|
|
525
|
+
let js = ssr ? "" : "window.__BOSIA_SSR__=false;";
|
|
526
|
+
if (title) js += `window.__BOSIA_TITLE__=${safeJsonStringify(title)};`;
|
|
527
|
+
if (pageDeps !== null || layoutDeps !== null)
|
|
528
|
+
js += `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`;
|
|
529
|
+
return js;
|
|
530
|
+
}
|
|
531
|
+
|
|
648
532
|
export function buildHtmlTail(
|
|
649
533
|
body: string,
|
|
650
534
|
pageData: any,
|
|
@@ -657,6 +541,7 @@ export function buildHtmlTail(
|
|
|
657
541
|
pageDeps: any = null,
|
|
658
542
|
layoutDeps: any[] | null = null,
|
|
659
543
|
segments?: AppHtmlSegments,
|
|
544
|
+
ssrTitle?: string,
|
|
660
545
|
): string {
|
|
661
546
|
// Same rebase as buildHtml — the streamed tail carries the identical markup.
|
|
662
547
|
body = rebaseHtmlAttrs(B, body);
|
|
@@ -675,14 +560,8 @@ export function buildHtmlTail(
|
|
|
675
560
|
if (formData != null) {
|
|
676
561
|
out += `\n<script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`;
|
|
677
562
|
}
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
pageDeps !== null || layoutDeps !== null
|
|
681
|
-
? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
|
|
682
|
-
: "";
|
|
683
|
-
if (ssrFlag || depsInject) {
|
|
684
|
-
out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
|
|
685
|
-
}
|
|
563
|
+
const sys = sysScriptBody(ssr, ssrTitle, pageDeps, layoutDeps);
|
|
564
|
+
if (sys) out += `\n<script${n}>${sys}</script>`;
|
|
686
565
|
out += `\n<script${n} type="module" src="${ENTRY}"></script>`;
|
|
687
566
|
} else if (isDev) {
|
|
688
567
|
out += `\n<script${n}>!function r(){var e=new EventSource("${SSE}");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
|
package/src/core/prerender.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, cpSync, existsSync } from "fs";
|
|
1
|
+
import { writeFileSync, mkdirSync, cpSync, existsSync, readFileSync } from "fs";
|
|
2
2
|
import { createServer } from "net";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import type { RouteManifest, TrailingSlash } from "./types.ts";
|
|
5
|
+
import type { RuntimeTarget } from "./types/plugin.ts";
|
|
5
6
|
|
|
6
7
|
import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
|
|
7
8
|
import { currentBase } from "./appBase.ts";
|
|
@@ -28,6 +29,26 @@ export function getEphemeralPort(): Promise<number> {
|
|
|
28
29
|
|
|
29
30
|
const CORE_DIR = import.meta.dir;
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* True when the app's wrangler config serves `404.html` for unknown paths
|
|
34
|
+
* (`not_found_handling: "404-page"`) and `public/404.html` doesn't provide one,
|
|
35
|
+
* so a failed 404 probe must fail the build. Reads the text, skipping whole-line
|
|
36
|
+
* `//` and `#` comments; a block comment around the key still counts.
|
|
37
|
+
*/
|
|
38
|
+
export function needsStatic404(cwd = process.cwd()): boolean {
|
|
39
|
+
if (existsSync(join(cwd, "public", "404.html"))) return false;
|
|
40
|
+
for (const name of ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]) {
|
|
41
|
+
const file = join(cwd, name);
|
|
42
|
+
if (!existsSync(file)) continue;
|
|
43
|
+
const text = readFileSync(file, "utf-8")
|
|
44
|
+
.split("\n")
|
|
45
|
+
.filter((line) => !/^\s*(\/\/|#)/.test(line))
|
|
46
|
+
.join("\n");
|
|
47
|
+
return /not_found_handling"?\s*[:=]\s*"404-page"/.test(text);
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
|
|
32
53
|
const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
|
|
33
54
|
|
|
@@ -157,23 +178,44 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
|
|
|
157
178
|
return results.flat();
|
|
158
179
|
}
|
|
159
180
|
|
|
160
|
-
export async function prerenderStaticRoutes(
|
|
181
|
+
export async function prerenderStaticRoutes(
|
|
182
|
+
manifest: RouteManifest,
|
|
183
|
+
target: RuntimeTarget = "bun",
|
|
184
|
+
): Promise<void> {
|
|
161
185
|
const targets = await detectPrerenderRoutes(manifest);
|
|
162
|
-
|
|
186
|
+
// On Workers the worker answers unknown paths, so 404.html is never required.
|
|
187
|
+
const require404 = target !== "workers" && needsStatic404();
|
|
188
|
+
// No routes to prerender: start the server only for the required 404.html.
|
|
189
|
+
if (targets.length === 0 && !require404) return;
|
|
163
190
|
|
|
164
|
-
console.log(
|
|
191
|
+
console.log(
|
|
192
|
+
targets.length
|
|
193
|
+
? `\n🖨️ Prerendering ${targets.length} route(s)...`
|
|
194
|
+
: "\n🖨️ Building 404.html (wrangler sets 404-page)...",
|
|
195
|
+
);
|
|
165
196
|
|
|
166
197
|
const port = await getEphemeralPort();
|
|
198
|
+
// No BOSIA_REUSE_PORT: the child must fail to bind rather than share a port
|
|
199
|
+
// with another server, whose pages we'd then save as ours. The ready loop
|
|
200
|
+
// stops once the child exits, but a server that grabbed the port first can
|
|
201
|
+
// still answer /_health before the child dies. Have /_health echo a nonce
|
|
202
|
+
// from the child's env if that race ever shows up.
|
|
203
|
+
const { BOSIA_REUSE_PORT: _, ...env } = process.env;
|
|
167
204
|
const child = Bun.spawn(["bun", "run", `${OUT_DIR}/server/index.js`], {
|
|
168
205
|
env: {
|
|
169
|
-
...
|
|
206
|
+
...env,
|
|
170
207
|
NODE_ENV: "production",
|
|
171
208
|
PORT: String(port),
|
|
172
209
|
NODE_PATH: BOSIA_NODE_PATH,
|
|
173
210
|
},
|
|
174
211
|
stdout: "ignore",
|
|
175
|
-
|
|
212
|
+
// Show render errors (a throwing +error.svelte, a crashing loader).
|
|
213
|
+
stderr: "inherit",
|
|
176
214
|
});
|
|
215
|
+
// exitCode stays null when a signal kills the child (OOM, segfault), so
|
|
216
|
+
// watch `exited` instead.
|
|
217
|
+
let exited = false;
|
|
218
|
+
child.exited.then(() => (exited = true));
|
|
177
219
|
|
|
178
220
|
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
|
179
221
|
let receivedSignal: NodeJS.Signals | null = null;
|
|
@@ -191,7 +233,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
191
233
|
const base = `http://localhost:${port}${currentBase()}`;
|
|
192
234
|
let ready = false;
|
|
193
235
|
const deadline = Date.now() + 10_000;
|
|
194
|
-
while (Date.now() < deadline) {
|
|
236
|
+
while (Date.now() < deadline && !exited) {
|
|
195
237
|
try {
|
|
196
238
|
const res = await fetch(`${base}/_health`);
|
|
197
239
|
if (res.ok) {
|
|
@@ -204,8 +246,14 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
204
246
|
await Bun.sleep(50);
|
|
205
247
|
}
|
|
206
248
|
|
|
207
|
-
if (!ready) {
|
|
208
|
-
|
|
249
|
+
if (!ready || exited) {
|
|
250
|
+
const why = child.signalCode ?? `exit code ${child.exitCode ?? "none"}`;
|
|
251
|
+
if (require404)
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Prerender server failed to start (${why}), so 404.html could not be built, but ` +
|
|
254
|
+
`wrangler sets not_found_handling "404-page". Fix the server, or add public/404.html.`,
|
|
255
|
+
);
|
|
256
|
+
console.error(`❌ Prerender server failed to start (${why})`);
|
|
209
257
|
return;
|
|
210
258
|
}
|
|
211
259
|
|
|
@@ -286,7 +334,38 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
286
334
|
Array.from({ length: Math.min(PRERENDER_CONCURRENCY, targets.length) }, worker),
|
|
287
335
|
);
|
|
288
336
|
|
|
289
|
-
|
|
337
|
+
// Static hosts (Cloudflare assets) serve index.html with a 200 for unknown
|
|
338
|
+
// paths unless a 404.html exists. Save the server's own 404 page while it's up.
|
|
339
|
+
// Goes to dist/static, not prerendered/, so the Bun server doesn't route /404
|
|
340
|
+
// to it, though it (and static hosts) still serve /404.html with a 200.
|
|
341
|
+
// Skipped on Workers: the worker answers unknown paths, and an uploaded file
|
|
342
|
+
// would only add that soft 404. A public/404.html overwrites it.
|
|
343
|
+
// One probe, one locale: add per-locale probe paths (e.g. /id/404.html) when asked.
|
|
344
|
+
if (target !== "workers")
|
|
345
|
+
try {
|
|
346
|
+
const res = await fetch(`${base}/__bosia-404-probe`, {
|
|
347
|
+
signal: AbortSignal.timeout(PRERENDER_TIMEOUT),
|
|
348
|
+
});
|
|
349
|
+
if (res.status !== 404) throw new Error(`expected a 404 page, got ${res.status}`);
|
|
350
|
+
// No +error.svelte (or it crashed) → plain-text "Not Found", not a page.
|
|
351
|
+
if (!res.headers.get("content-type")?.includes("text/html"))
|
|
352
|
+
throw new Error(`expected an HTML 404 page, got ${res.headers.get("content-type")}`);
|
|
353
|
+
mkdirSync(`${OUT_DIR}/static`, { recursive: true });
|
|
354
|
+
writeFileSync(`${OUT_DIR}/static/404.html`, await res.text());
|
|
355
|
+
console.log(` ✅ 404 page → ${OUT_DIR}/static/404.html`);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
// Only a deploy that serves 404.html needs it; elsewhere (an app whose
|
|
358
|
+
// hooks redirect every request) a missing one is fine, so just warn.
|
|
359
|
+
const reason = (err as Error).message;
|
|
360
|
+
if (require404)
|
|
361
|
+
throw new Error(
|
|
362
|
+
`404.html could not be built (${reason}), but wrangler sets ` +
|
|
363
|
+
`not_found_handling "404-page". Make unknown paths answer 404, or add public/404.html.`,
|
|
364
|
+
);
|
|
365
|
+
console.warn(` ⚠️ 404.html skipped: ${reason}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (targets.length) console.log("✅ Prerendering complete");
|
|
290
369
|
} finally {
|
|
291
370
|
for (const sig of signals) process.off(sig, onSignal);
|
|
292
371
|
child.kill();
|
package/src/core/renderer.ts
CHANGED
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
compressBytes,
|
|
35
35
|
encodeForRequest,
|
|
36
36
|
appHtmlHasTitle,
|
|
37
|
-
hasTitle,
|
|
38
37
|
isDev,
|
|
39
38
|
withPreloadLink,
|
|
40
39
|
type Encoded,
|
|
@@ -46,6 +45,7 @@ import { reportDevErrorFromCatch } from "./devErrorReport.ts";
|
|
|
46
45
|
import { dev500Response } from "./dev-500.ts";
|
|
47
46
|
import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
|
|
48
47
|
import { getAppHtmlSegments } from "./appHtml.ts";
|
|
48
|
+
import { errorTitle, hasTitle } from "./headScan.ts";
|
|
49
49
|
import type { AppHtmlSegments } from "./appHtml.ts";
|
|
50
50
|
|
|
51
51
|
// Shared, stateless — one instance instead of a fresh allocation per stream.
|
|
@@ -146,7 +146,9 @@ function parseTimeout(raw: string | undefined, fallback: number): number {
|
|
|
146
146
|
const LOAD_TIMEOUT = parseTimeout(process.env.LOAD_TIMEOUT, 5000);
|
|
147
147
|
const METADATA_TIMEOUT = parseTimeout(process.env.METADATA_TIMEOUT, 3000);
|
|
148
148
|
|
|
149
|
-
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);
|
|
150
152
|
if (ms <= 0) return promise;
|
|
151
153
|
let timer: Timer;
|
|
152
154
|
return Promise.race([
|
|
@@ -356,6 +358,33 @@ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
|
|
|
356
358
|
};
|
|
357
359
|
}
|
|
358
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
|
+
|
|
359
388
|
// ─── Route Data Loader ───────────────────────────────────
|
|
360
389
|
// Runs layout + page server loaders for a given URL.
|
|
361
390
|
// Used by both SSR and the /__bosia/data JSON endpoint.
|
|
@@ -485,6 +514,22 @@ export async function loadRouteData(
|
|
|
485
514
|
: null;
|
|
486
515
|
pageP?.catch(() => {});
|
|
487
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
|
+
|
|
488
533
|
// Settle root → leaf. The first failure wins, carrying the data of the
|
|
489
534
|
// layers above it — the same error a serial run would have produced.
|
|
490
535
|
for (let i = 0; i < route.layoutServers.length; i++) {
|
|
@@ -496,6 +541,15 @@ export async function loadRouteData(
|
|
|
496
541
|
layoutData[ls.depth] = omit ? null : data;
|
|
497
542
|
layoutDeps[ls.depth] = omit ? null : deps;
|
|
498
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
|
+
}
|
|
499
553
|
if (isRedirect(err)) throw err;
|
|
500
554
|
if (isHttpError(err)) {
|
|
501
555
|
stampErrorContext(
|
|
@@ -830,6 +884,7 @@ export async function renderSSRStream(
|
|
|
830
884
|
data.pageDeps,
|
|
831
885
|
data.layoutDeps,
|
|
832
886
|
appHtmlSegments,
|
|
887
|
+
metadata?.title,
|
|
833
888
|
);
|
|
834
889
|
return compress(
|
|
835
890
|
html,
|
|
@@ -900,6 +955,7 @@ export async function renderSSRStream(
|
|
|
900
955
|
data.pageDeps,
|
|
901
956
|
data.layoutDeps,
|
|
902
957
|
appHtmlSegments,
|
|
958
|
+
metadata?.title,
|
|
903
959
|
),
|
|
904
960
|
) as Uint8Array<ArrayBuffer>;
|
|
905
961
|
// Stored with the cache entry too, so hits carry the same Link header.
|
|
@@ -1161,7 +1217,7 @@ export async function renderErrorPage(
|
|
|
1161
1217
|
// head, so a real title hasTitle misses still comes first), except when
|
|
1162
1218
|
// app.html has its own <title>: that would come first, so the status title
|
|
1163
1219
|
// takes the metadata slot ahead of it instead.
|
|
1164
|
-
const statusTitle =
|
|
1220
|
+
const statusTitle = errorTitle(status, message);
|
|
1165
1221
|
const errMeta = (head: string): Metadata | null =>
|
|
1166
1222
|
appHtmlHasTitle(appHtmlSegments) && !hasTitle(head) ? { title: statusTitle } : null;
|
|
1167
1223
|
|
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
|
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
<div class="mt-4 flex gap-3">
|
|
19
19
|
<a
|
|
20
|
-
href="/products"
|
|
20
|
+
href="/dashboard/products"
|
|
21
21
|
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
|
22
22
|
>
|
|
23
|
-
|
|
23
|
+
Manage products
|
|
24
24
|
</a>
|
|
25
25
|
<a
|
|
26
26
|
href="/login"
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
<div class="mt-4 flex gap-3">
|
|
19
19
|
<a
|
|
20
|
-
href="/products"
|
|
20
|
+
href="/dashboard/products"
|
|
21
21
|
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
|
22
22
|
>
|
|
23
|
-
|
|
23
|
+
Manage products
|
|
24
24
|
</a>
|
|
25
25
|
<a
|
|
26
26
|
href="/login"
|