bosia 1.2.0 → 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 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
@@ -194,4 +194,4 @@ my-app/
194
194
 
195
195
  ## License
196
196
 
197
- MIT
197
+ [MIT](https://github.com/bosapi/bosia/blob/main/LICENSE) © 2026 Bosapi and contributors. See [AUTHORS](https://github.com/bosapi/bosia/blob/main/AUTHORS).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
package/src/ambient.d.ts CHANGED
@@ -15,6 +15,9 @@ declare module "bosia:routes" {
15
15
  prerender: boolean;
16
16
  }>;
17
17
 
18
+ /** What client navigation titles a page that sets no title of its own. */
19
+ export const defaultTitle: string;
20
+
18
21
  export const serverRoutes: Array<{
19
22
  pattern: string;
20
23
  id: string;
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
+ }
@@ -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
- generateRoutesFile(manifest);
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);
@@ -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 } 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
- let s = 0;
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,11 @@
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
+ const pageShown = !showLoading && !appState.errorComponent;
262
313
  showLoading = false;
263
314
  LoadingComponent = null;
264
- currentLayoutPaths = destLayoutPaths;
265
315
  lastSettledPath = pathname;
266
316
  lastNavKey = navKey;
267
317
  navDoneTimer = setTimeout(() => {
@@ -301,6 +351,8 @@
301
351
  ]);
302
352
  if (cancelled) return;
303
353
  layoutComponents = layoutModsForError.map((m: any) => m.default);
354
+ currentLayoutPaths = destLayoutPaths.slice(0, K);
355
+ titleNav.seq++; // the error branch remounts the layouts
304
356
  const newLayoutData: Record<string, any>[] = [];
305
357
  for (let i = 0; i < K; i++) newLayoutData.push({});
306
358
  appState.layoutData = newLayoutData;
@@ -316,6 +368,11 @@
316
368
  }
317
369
  return;
318
370
  }
371
+ // Layout paths are recorded only once they render: a redirect above
372
+ // leaves the mounted ones in place.
373
+ const shared = sharedDepth(currentLayoutPaths, destLayoutPaths);
374
+ currentLayoutPaths = destLayoutPaths;
375
+ const pageChanged = !pageShown || PageComponent !== pageMod.default;
319
376
  PageComponent = pageMod.default;
320
377
  layoutComponents = layoutMods.map((m: any) => m.default);
321
378
 
@@ -408,9 +465,19 @@
408
465
  if (result || !match.route.hasServerData) {
409
466
  const md = result?.metadata;
410
467
  document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
411
- // A page that declares no title keeps the previous one rather than flashing
412
- // the "Bosia App" fallback. Give metadata() a title if that matters.
413
- if (md?.title) document.title = md.title;
468
+ // With no metadata() title, a page gets the one a hard load would give
469
+ // it: the layouts' (baseTitle), else app.html's <title>, else "Bosia
470
+ // App". A layout that stays mounted never writes its title again, so it
471
+ // is put back here. Set before Svelte flushes, so a freshly mounted
472
+ // <svelte:head><title> still wins. On the same page (invalidate, a form
473
+ // action, ?page=2) it stays once a component like <Seo> took it over.
474
+ if (md?.title) {
475
+ document.title = md.title;
476
+ navTitle = document.title;
477
+ } else if (pageChanged || document.title === navTitle) {
478
+ document.title = baseTitle(shared);
479
+ navTitle = document.title;
480
+ }
414
481
  if (md?.lang) document.documentElement.lang = md.lang;
415
482
  const emit = (tag: "meta" | "link", attrs: Record<string, string | undefined>) => {
416
483
  const el = document.createElement(tag);
@@ -424,6 +491,7 @@
424
491
  for (const l of md?.link ?? [])
425
492
  emit("link", { rel: l.rel, href: l.href, hreflang: l.hreflang });
426
493
  }
494
+ titleNav.seq++; // the probes read again as this flushes
427
495
 
428
496
  settle({ url, params: match.params });
429
497
  });
@@ -480,10 +548,12 @@
480
548
 
481
549
  {#if index < leafDepth - 1}
482
550
  <Layout {data} {params}>
551
+ <TitleProbe depth={index} layout={Layout} />
483
552
  {@render renderLayout(index + 1, leafDepth)}
484
553
  </Layout>
485
554
  {:else}
486
555
  <Layout {data} {params}>
556
+ <TitleProbe depth={index} layout={Layout} />
487
557
  {#if ErrorComponent}
488
558
  <ErrorComponent {...errorProps ?? {}} {params} />
489
559
  {: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,132 @@
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
+ /** Whether a chunk of head markup already sets the document title. Matches a
9
+ * <title> with attributes too. Ignores one in a comment, an attribute value, a
10
+ * script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
11
+ * (which only labels the icon). */
12
+ export function hasTitle(html: string): boolean {
13
+ return titleTagEnd(html) !== -1;
14
+ }
15
+
16
+ /** The raw text of the <title> that hasTitle finds, or null when there is none.
17
+ * Whitespace is collapsed and trimmed as document.title does; entities are left
18
+ * for the browser to decode. An unclosed <title> runs to the end, as in a browser. */
19
+ export function titleText(html: string): string | null {
20
+ const start = titleTagEnd(html);
21
+ if (start === -1) return null;
22
+ const m = /<\/title(?=[\t\n\f\r />])/i.exec(html.slice(start));
23
+ const raw = m ? html.slice(start, start + m.index) : html.slice(start);
24
+ return collapseTitle(raw);
25
+ }
26
+
27
+ /** A title as document.title reads it back: HTML whitespace runs collapsed to
28
+ * one space, and trimmed. */
29
+ export function collapseTitle(text: string): string {
30
+ return text.replace(/[\t\n\f\r ]+/g, " ").trim();
31
+ }
32
+
33
+ /** Index just past the `>` of the first real <title> tag, or -1. */
34
+ function titleTagEnd(html: string): number {
35
+ // A single forward scan, not a regex: a lazy `<script…</script>` match retries
36
+ // from every unclosed `<script` and goes quadratic on hostile {@html} output.
37
+ // Each tag is skipped whole, quotes included, so nothing inside an attribute
38
+ // looks like a tag. This is a heuristic, not a full HTML tokenizer.
39
+ let unclosed: Set<string> | undefined;
40
+ let i = 0;
41
+ while ((i = html.indexOf("<", i)) !== -1) {
42
+ if (html.startsWith("<!--", i)) {
43
+ i = commentEnd(html, i);
44
+ if (i === -1) return -1; // unclosed comment runs to the end
45
+ continue;
46
+ }
47
+ if (!isAsciiAlpha(html.charCodeAt(i + 1))) {
48
+ i++;
49
+ continue;
50
+ }
51
+ const nameEnd = tagNameEnd(html, i + 1);
52
+ const tagEnd = openTagEnd(html, nameEnd);
53
+ if (tagEnd === -1) return -1; // the rest is inside an unfinished tag
54
+ const name = nameEnd - i - 1 <= MAX_NAME ? html.slice(i + 1, nameEnd).toLowerCase() : "";
55
+ if (name === "title") return tagEnd;
56
+ i = tagEnd;
57
+ const close = SKIPPED_TAGS.get(name);
58
+ // Only foreign content like <svg/> honors the self-closing slash.
59
+ const selfClosed = name === "svg" && html.charCodeAt(tagEnd - 2) === 47;
60
+ if (!close || selfClosed || unclosed?.has(name)) continue;
61
+ // An opener with no closer is read as a plain tag, and remembered, so
62
+ // later openers of that tag don't search to the end again.
63
+ close.lastIndex = tagEnd;
64
+ const gt = close.exec(html) ? html.indexOf(">", close.lastIndex) : -1;
65
+ if (gt === -1) (unclosed ??= new Set()).add(name);
66
+ else i = gt + 1;
67
+ }
68
+ return -1;
69
+ }
70
+
71
+ /** `</tag` followed by whitespace, `/` or `>`; the caller finds the `>` itself,
72
+ * since a `[^>]*>` here would rescan to the end for every candidate. */
73
+ const SKIPPED_TAGS = new Map(
74
+ ["script", "style", "noscript", "template", "svg"].map((t) => [
75
+ t,
76
+ new RegExp(`</${t}(?=[\\t\\n\\f\\r />])`, "gi"),
77
+ ]),
78
+ );
79
+
80
+ /** Longest tag name the scan needs to tell apart; longer names are never sliced. */
81
+ const MAX_NAME = Math.max("title".length, ...[...SKIPPED_TAGS.keys()].map((t) => t.length));
82
+
83
+ /** Index just past the comment opening at `start`, or -1 when it never closes.
84
+ * `<!-->` and `<!--->` are complete empty comments. */
85
+ function commentEnd(html: string, start: number): number {
86
+ if (html.startsWith(">", start + 4)) return start + 5;
87
+ if (html.startsWith("->", start + 4)) return start + 6;
88
+ const end = html.indexOf("-->", start + 4);
89
+ return end === -1 ? -1 : end + 3;
90
+ }
91
+
92
+ /** End of the tag name starting at `start`: the first whitespace, `/` or `>`. */
93
+ function tagNameEnd(html: string, start: number): number {
94
+ let j = start;
95
+ while (j < html.length) {
96
+ const c = html.charCodeAt(j);
97
+ if (isHtmlSpace(c) || c === 47 || c === 62) break;
98
+ j++;
99
+ }
100
+ return j;
101
+ }
102
+
103
+ /** Index just past the `>` closing a start tag, skipping quoted attribute
104
+ * values, or -1 when the tag never closes. */
105
+ function openTagEnd(html: string, from: number): number {
106
+ for (let j = from; j < html.length; j++) {
107
+ const c = html.charCodeAt(j);
108
+ if (c === 62) return j + 1;
109
+ if (c !== 61) continue; // `=` starts a value
110
+ do j++;
111
+ while (j < html.length && isHtmlSpace(html.charCodeAt(j)));
112
+ const q = html.charCodeAt(j);
113
+ if (q === 34 || q === 39) {
114
+ j = html.indexOf(html[j]!, j + 1);
115
+ if (j === -1) return -1;
116
+ continue;
117
+ }
118
+ // Unquoted: runs to whitespace or `>`, quotes and `=` included.
119
+ while (j < html.length && !isHtmlSpace(html.charCodeAt(j)) && html.charCodeAt(j) !== 62) j++;
120
+ j--;
121
+ }
122
+ return -1;
123
+ }
124
+
125
+ /** HTML whitespace: tab, LF, FF, CR, space. Not \s, which also matches NBSP. */
126
+ function isHtmlSpace(c: number): boolean {
127
+ return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
128
+ }
129
+
130
+ function isAsciiAlpha(c: number): boolean {
131
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
132
+ }
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 { findHeadEnd, interpolateSegment } from "./appHtml.ts";
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 ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
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, "&gt;");
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 ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
679
- const depsInject =
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>`;
@@ -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 { 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 withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
149
+ // A plain-function load() hands back a value, not a promise; wrap it first.
150
+ function withTimeout<T>(value: T | Promise<T>, ms: number, label: string): Promise<T> {
151
+ const promise = Promise.resolve(value);
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.
@@ -47,7 +47,10 @@ function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
47
47
  });
48
48
  }
49
49
 
50
- export function generateRoutesFile(manifest: RouteManifest): void {
50
+ /** `defaultTitle` is what a page with no title of its own is called: app.html's
51
+ * <title> as raw HTML text if it has one, else DEFAULT_TITLE — the same title
52
+ * SSR falls back to. Required, so a caller can't quietly drop app.html's. */
53
+ export function generateRoutesFile(manifest: RouteManifest, defaultTitle: string): void {
51
54
  const lines: string[] = ["// AUTO-GENERATED by bosia build — do not edit\n", ONCE_HELPER];
52
55
 
53
56
  const pages = sortRoutes(manifest.pages);
@@ -109,6 +112,8 @@ export function generateRoutesFile(manifest: RouteManifest): void {
109
112
  lines.push(" },");
110
113
  }
111
114
  lines.push("];\n");
115
+ // Unread on the server, but App.svelte imports it and is rendered there too.
116
+ lines.push(defaultTitleExport(defaultTitle));
112
117
 
113
118
  // serverRoutes
114
119
  lines.push("export const serverRoutes: Array<{");
@@ -169,7 +174,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
169
174
  writeFileSync(".bosia/routes.ts", lines.join("\n"));
170
175
 
171
176
  // Generate client-only routes file (no server imports that could pull in Node builtins)
172
- generateClientRoutesFile(pages, manifest.errorPage);
177
+ generateClientRoutesFile(pages, manifest.errorPage, defaultTitle);
173
178
 
174
179
  const pagePatterns = pages.map((p) => p.pattern).join(", ") || "(none)";
175
180
  console.log(`✅ Routes generated: .bosia/routes.ts`);
@@ -184,6 +189,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
184
189
  function generateClientRoutesFile(
185
190
  pages: RouteManifest["pages"],
186
191
  errorPage: RouteManifest["errorPage"],
192
+ defaultTitle: string,
187
193
  ): void {
188
194
  const lines: string[] = [
189
195
  "// AUTO-GENERATED by bosia build — client-only routes (no server imports)\n",
@@ -238,6 +244,7 @@ function generateClientRoutesFile(
238
244
  lines.push(" },");
239
245
  }
240
246
  lines.push("];\n");
247
+ lines.push(defaultTitleExport(defaultTitle));
241
248
 
242
249
  const ep = errorPage;
243
250
  lines.push(
@@ -264,6 +271,12 @@ const ONCE_HELPER = `function once<T>(load: () => Promise<T>): () => Promise<T>
264
271
  }
265
272
  `;
266
273
 
274
+ /** The title client navigation gives a page that sets none (client/App.svelte),
275
+ * as raw HTML text: the browser decodes its entities. */
276
+ function defaultTitleExport(title: string): string {
277
+ return `export const defaultTitle: string = ${JSON.stringify(title)};\n`;
278
+ }
279
+
267
280
  /** A memoized server-side import thunk for `src/routes/<routePath>`. */
268
281
  function serverImport(routePath: string): string {
269
282
  return `once(() => import(${JSON.stringify(toImportPath(routePath))}))`;
@@ -18,11 +18,6 @@ import type { PageRoute, ApiRoute, RouteManifest, TrailingSlash } from "./types.
18
18
 
19
19
  const ROUTES_DIR = "./src/routes";
20
20
 
21
- /**
22
- * Extract `export const trailingSlash = '...'` from a server module file via
23
- * regex. Static-string read only — runtime expressions return null. Build-time
24
- * scan avoids invoking server modules during the client bundle.
25
- */
26
21
  /**
27
22
  * Read `export const cache` from +page.svelte's `<script module>` at scan time.
28
23
  * Conservative on purpose — the flag lets the renderer skip caching, so a wrong
@@ -31,15 +26,11 @@ const ROUTES_DIR = "./src/routes";
31
26
  * else (dynamic expression, unreadable file) returns `null` so the renderer
32
27
  * imports the module and reads the real value.
33
28
  */
34
- function readPageCache(filePath: string): boolean | null {
35
- try {
36
- const src = readFileSync(filePath, "utf-8");
37
- if (!/export\s+const\s+cache\b/.test(src)) return true;
38
- if (/export\s+const\s+cache\s*(?::[^=]+)?=\s*false\b/.test(src)) return false;
39
- return null;
40
- } catch {
41
- return null;
42
- }
29
+ function pageCache(src: string | null): boolean | null {
30
+ if (src === null) return null;
31
+ if (!/export\s+const\s+cache\b/.test(src)) return true;
32
+ if (/export\s+const\s+cache\s*(?::[^=]+)?=\s*false\b/.test(src)) return false;
33
+ return null;
43
34
  }
44
35
 
45
36
  /** Literal `export const prerender = true` in a server module's source. */
@@ -62,27 +53,31 @@ export function prerenderSkipReason(src: string): string | null {
62
53
  }
63
54
 
64
55
  /** True when the page will really be prerendered — the client fetches its data as a static file. */
65
- function readPrerender(filePath: string): boolean {
66
- try {
67
- const src = readFileSync(filePath, "utf-8");
68
- return wantsPrerender(src) && prerenderSkipReason(src) === null;
69
- } catch {
70
- return false;
71
- }
56
+ function prerenderOf(src: string | null): boolean {
57
+ return src !== null && wantsPrerender(src) && prerenderSkipReason(src) === null;
72
58
  }
73
59
 
74
- function readTrailingSlash(filePath: string): TrailingSlash | null {
60
+ /** A route file's source, or null when it can't be read. */
61
+ function readSource(filePath: string): string | null {
75
62
  try {
76
- const src = readFileSync(filePath, "utf-8");
77
- const m = src.match(
78
- /export\s+const\s+trailingSlash\s*(?::\s*[^=]+)?=\s*["'](never|always|ignore)["']/,
79
- );
80
- return (m?.[1] ?? null) as TrailingSlash | null;
63
+ return readFileSync(filePath, "utf-8");
81
64
  } catch {
82
65
  return null;
83
66
  }
84
67
  }
85
68
 
69
+ /**
70
+ * Extract `export const trailingSlash = '...'` from a server module file via
71
+ * regex. Static-string read only — runtime expressions return null. Build-time
72
+ * scan avoids invoking server modules during the client bundle.
73
+ */
74
+ function trailingSlashOf(src: string | null): TrailingSlash | null {
75
+ const m = src?.match(
76
+ /export\s+const\s+trailingSlash\s*(?::\s*[^=]+)?=\s*["'](never|always|ignore)["']/,
77
+ );
78
+ return (m?.[1] ?? null) as TrailingSlash | null;
79
+ }
80
+
86
81
  export function scanRoutes(): RouteManifest {
87
82
  const pages: PageRoute[] = [];
88
83
  const apis: ApiRoute[] = [];
@@ -126,7 +121,7 @@ export function scanRoutes(): RouteManifest {
126
121
  path: layoutServerPath,
127
122
  depth: currentLayouts.length - 1,
128
123
  });
129
- const ts = readTrailingSlash(join(ROUTES_DIR, layoutServerPath));
124
+ const ts = trailingSlashOf(readSource(join(ROUTES_DIR, layoutServerPath)));
130
125
  if (ts) currentTrailingSlash = ts;
131
126
  }
132
127
  if (items.some((i) => i.isFile() && i.name === "+error.svelte")) {
@@ -153,10 +148,12 @@ export function scanRoutes(): RouteManifest {
153
148
  ? rel("+page.server.ts")
154
149
  : null;
155
150
 
156
- const pageTs = pageServerFile ? readTrailingSlash(join(ROUTES_DIR, pageServerFile)) : null;
151
+ const pageServerSrc = pageServerFile ? readSource(join(ROUTES_DIR, pageServerFile)) : null;
152
+ const pageTs = trailingSlashOf(pageServerSrc);
157
153
  const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
158
154
 
159
155
  const pageFile = rel("+page.svelte");
156
+ const pageSrc = readSource(join(ROUTES_DIR, pageFile));
160
157
  pages.push({
161
158
  pattern: toUrlPath(urlSegments),
162
159
  id: `/${dir}`,
@@ -167,8 +164,8 @@ export function scanRoutes(): RouteManifest {
167
164
  layoutServers: [...currentLayoutServers],
168
165
  errorPages: [...currentErrorPages],
169
166
  trailingSlash: effectiveTs,
170
- cache: readPageCache(join(ROUTES_DIR, pageFile)),
171
- prerender: pageServerFile ? readPrerender(join(ROUTES_DIR, pageServerFile)) : false,
167
+ cache: pageCache(pageSrc),
168
+ prerender: prerenderOf(pageServerSrc),
172
169
  });
173
170
  }
174
171
 
@@ -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
- Browse products
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
- Browse products
23
+ Manage products
24
24
  </a>
25
25
  <a
26
26
  href="/login"