bosia 0.1.1 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
package/src/cli/add.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { join, dirname } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
3
  import { spawn } from "bun";
4
+ import * as p from "@clack/prompts";
4
5
 
5
6
  // ─── bosia add <component> ────────────────────────────────
6
7
  // Fetches a component from the GitHub registry (or local registry
7
- // with --local) and copies it into src/lib/components/ui/<name>/.
8
+ // with --local) and copies it into src/lib/components/<path>/.
9
+ //
10
+ // Path-based names:
11
+ // bosia add button → src/lib/components/ui/button/
12
+ // bosia add shop/cart → src/lib/components/shop/cart/
8
13
 
9
14
  const REMOTE_BASE = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
10
15
 
@@ -38,29 +43,52 @@ export async function runAdd(name: string | undefined, flags: string[] = []) {
38
43
  await addComponent(name, true);
39
44
  }
40
45
 
46
+ /**
47
+ * Resolve the destination path for a component.
48
+ * - "button" → "ui/button" (default ui/ prefix)
49
+ * - "shop/cart" → "shop/cart" (explicit path used as-is)
50
+ */
51
+ function resolveDestPath(name: string): string {
52
+ return name.includes("/") ? name : `ui/${name}`;
53
+ }
54
+
41
55
  export async function addComponent(name: string, root = false) {
42
- if (installed.has(name)) return;
43
- installed.add(name);
56
+ // Resolve the full path (e.g. "button" → "ui/button", "shop/cart" stays "shop/cart")
57
+ const fullPath = resolveDestPath(name);
58
+
59
+ if (installed.has(fullPath)) return;
60
+ installed.add(fullPath);
44
61
 
45
62
  console.log(root ? `⬡ Installing component: ${name}\n` : ` 📦 Dependency: ${name}`);
46
63
 
47
- const meta = await readMeta(name);
64
+ const meta = await readMeta(fullPath);
48
65
 
49
66
  // Install component dependencies first (recursive)
50
67
  for (const dep of meta.dependencies) {
51
68
  await addComponent(dep, false);
52
69
  }
53
70
 
54
- // Download/copy component files into src/lib/components/ui/<name>/
55
- const destDir = join(process.cwd(), "src", "lib", "components", "ui", name);
71
+ // Check if component already exists
72
+ const destDir = join(process.cwd(), "src", "lib", "components", fullPath);
73
+ if (existsSync(destDir)) {
74
+ const replace = await p.confirm({
75
+ message: `Component "${name}" already exists at src/lib/components/${fullPath}/. Replace it?`,
76
+ });
77
+ if (p.isCancel(replace) || !replace) {
78
+ console.log(` ⏭️ Skipped ${name}`);
79
+ return;
80
+ }
81
+ }
82
+
83
+ // Download/copy component files into src/lib/components/<fullPath>/
56
84
  mkdirSync(destDir, { recursive: true });
57
85
 
58
86
  for (const file of meta.files) {
59
- const content = await readFile(name, file);
87
+ const content = await readFile(fullPath, file);
60
88
  const dest = join(destDir, file);
61
89
  mkdirSync(dirname(dest), { recursive: true });
62
90
  writeFileSync(dest, content, "utf-8");
63
- console.log(` ✍️ src/lib/components/ui/${name}/${file}`);
91
+ console.log(` ✍️ src/lib/components/${fullPath}/${file}`);
64
92
  }
65
93
 
66
94
  // Install npm dependencies
@@ -78,7 +106,7 @@ export async function addComponent(name: string, root = false) {
78
106
  }
79
107
  }
80
108
 
81
- if (root) console.log(`\n✅ ${name} installed at src/lib/components/ui/${name}/`);
109
+ if (root) console.log(`\n✅ ${name} installed at src/lib/components/${fullPath}/`);
82
110
  }
83
111
 
84
112
  // ─── Ensure $lib/utils.ts exists ─────────────────────────────
package/src/cli/index.ts CHANGED
@@ -64,7 +64,8 @@ Examples:
64
64
  bosia dev
65
65
  bosia build
66
66
  bosia start
67
- bosia add button
67
+ bosia add button → src/lib/components/ui/button/
68
+ bosia add shop/cart → src/lib/components/shop/cart/
68
69
  bosia feat login
69
70
  `);
70
71
  break;
package/src/core/build.ts CHANGED
@@ -7,7 +7,7 @@ import { scanRoutes } from "./scanner.ts";
7
7
  import { generateRoutesFile } from "./routeFile.ts";
8
8
  import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
9
9
  import { makeBosiaPlugin } from "./plugin.ts";
10
- import { prerenderStaticRoutes } from "./prerender.ts";
10
+ import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
11
11
  import { loadEnv, classifyEnvVars } from "./env.ts";
12
12
  import { generateEnvModules } from "./envCodegen.ts";
13
13
  import { BOSIA_NODE_PATH, resolveBosiaBin } from "./paths.ts";
@@ -144,7 +144,7 @@ mkdirSync("./dist", { recursive: true });
144
144
  const distManifest = {
145
145
  js: jsFiles,
146
146
  css: cssFiles,
147
- entry: jsFiles.find(f => f.startsWith("hydrate")) ?? jsFiles[0] ?? "hydrate.js",
147
+ entry: jsFiles.find(f => f === "hydrate.js") ?? jsFiles.find(f => f.startsWith("hydrate")) ?? "hydrate.js",
148
148
  serverEntry,
149
149
  };
150
150
  writeFileSync("./dist/manifest.json", JSON.stringify(distManifest, null, 2));
@@ -154,4 +154,7 @@ console.log(`✅ Server entry: dist/server/${serverEntry}`);
154
154
  // 9. Prerender static routes
155
155
  await prerenderStaticRoutes(manifest);
156
156
 
157
+ // 10. Generate static site output (HTML + client assets + public → dist/static/)
158
+ generateStaticSite();
159
+
157
160
  console.log("\n🎉 Build complete!");
@@ -2,7 +2,7 @@
2
2
  import { router } from "./router.svelte.ts";
3
3
  import { findMatch } from "../matcher.ts";
4
4
  import { clientRoutes } from "bosia:routes";
5
- import { consumePrefetch, prefetchCache } from "./prefetch.ts";
5
+ import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
6
6
 
7
7
  let {
8
8
  ssrMode = false,
@@ -45,12 +45,12 @@
45
45
 
46
46
  const isFirst = firstNav;
47
47
  firstNav = false;
48
- if (!isFirst) {
49
- formData = null;
50
- if (navDoneTimer) { clearTimeout(navDoneTimer); navDoneTimer = null; }
51
- navDone = false;
52
- navigating = true;
53
- }
48
+ if (isFirst) return; // Initial hydration — data already in SSR props, no fetch needed
49
+
50
+ formData = null;
51
+ if (navDoneTimer) { clearTimeout(navDoneTimer); navDoneTimer = null; }
52
+ navDone = false;
53
+ navigating = true;
54
54
 
55
55
  // Load components + data in parallel, then update state atomically
56
56
  // to avoid a flash of stale/empty data before the fetch completes.
@@ -59,7 +59,7 @@
59
59
  const dataFetch = cached
60
60
  ? Promise.resolve(cached)
61
61
  : match.route.hasServerData
62
- ? fetch(`/__bosia/data?path=${encodeURIComponent(path)}`).then(r => r.json()).catch(() => null)
62
+ ? fetch(dataUrl(path)).then(r => r.json()).catch(() => null)
63
63
  : Promise.resolve(null);
64
64
 
65
65
  Promise.all([
@@ -75,7 +75,8 @@
75
75
  router.navigate(result.redirect);
76
76
  return;
77
77
  }
78
- if (result?.error) {
78
+ if (result?.error || (result === null && match.route.hasServerData)) {
79
+ // Data fetch failed (e.g. static hosting with no server) — full page load
79
80
  window.location.href = path;
80
81
  return;
81
82
  }
@@ -84,6 +85,20 @@
84
85
  pageData = result?.pageData ?? {};
85
86
  layoutData = result?.layoutData ?? [];
86
87
  routeParams = result?.pageData?.params ?? match.params;
88
+
89
+ // Update document title and meta description from server metadata
90
+ if (result?.metadata) {
91
+ if (result.metadata.title) document.title = result.metadata.title;
92
+ if (result.metadata.description) {
93
+ let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
94
+ if (!meta) {
95
+ meta = document.createElement("meta");
96
+ meta.name = "description";
97
+ document.head.appendChild(meta);
98
+ }
99
+ meta.content = result.metadata.description;
100
+ }
101
+ }
87
102
  });
88
103
 
89
104
  return () => { cancelled = true; };
@@ -2,6 +2,13 @@
2
2
  // Supports `data-bosia-preload="hover"` and `data-bosia-preload="viewport"`
3
3
  // on <a> elements or their ancestors.
4
4
 
5
+ /** Builds the `/__bosia/data/…` URL for a given client path. */
6
+ export function dataUrl(path: string): string {
7
+ const url = new URL(path, window.location.origin);
8
+ let p = url.pathname.replace(/\/$/, "");
9
+ return `/__bosia/data${p || "/index"}.json${url.search}`;
10
+ }
11
+
5
12
  export const prefetchCache = new Map<string, any>();
6
13
 
7
14
  // In-flight fetch deduplication
@@ -22,7 +29,7 @@ export async function prefetchPath(path: string): Promise<void> {
22
29
 
23
30
  pending.add(path);
24
31
  try {
25
- const res = await fetch(`/__bosia/data?path=${encodeURIComponent(path)}`);
32
+ const res = await fetch(dataUrl(path));
26
33
  if (res.ok) {
27
34
  prefetchCache.set(path, await res.json());
28
35
  }
package/src/core/html.ts CHANGED
@@ -96,8 +96,9 @@ export function buildHtml(
96
96
  ${head}
97
97
  ${cssLinks}
98
98
  <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">
99
+ <script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>
99
100
  </head>
100
- <body data-bosia-preload="hover">
101
+ <body>
101
102
  <div id="app">${body}</div>${scripts}
102
103
  </body>
103
104
  </html>`;
@@ -121,6 +122,7 @@ export function buildHtmlShellOpen(): string {
121
122
  ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n` +
122
123
  ` ${cssLinks}\n` +
123
124
  ` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
125
+ ` <script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
124
126
  ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`;
125
127
  return _shellOpen;
126
128
  }
@@ -149,7 +151,7 @@ export function buildMetadataChunk(metadata: Metadata | null): string {
149
151
  } else {
150
152
  out += ` <title>Bosia App</title>\n`;
151
153
  }
152
- out += `</head>\n<body data-bosia-preload="hover">\n${SPINNER}`;
154
+ out += `</head>\n<body>\n${SPINNER}`;
153
155
  return out;
154
156
  }
155
157
 
@@ -32,6 +32,9 @@ function matchPattern(
32
32
  const paramName = catchallMatch[2]!;
33
33
  if (prefix === "" || pathname.startsWith(prefix + "/") || pathname === prefix) {
34
34
  const rest = prefix ? pathname.slice(prefix.length + 1) : pathname.slice(1);
35
+ // Don't let a root catch-all match "/" with an empty slug.
36
+ // If you want the catch-all to also serve "/", add an explicit +page.svelte at the root.
37
+ if (!prefix && rest === "") return null;
35
38
  return { [paramName]: rest };
36
39
  }
37
40
  return null;
@@ -4,7 +4,19 @@ import { join, dirname } from "path";
4
4
  // Resolves:
5
5
  // bosia:routes → .bosia/routes.ts (generated route map)
6
6
  // $env → .bosia/env.server.ts (bun) or .bosia/env.client.ts (browser)
7
- // $lib/* src/lib/* (user library alias)
7
+ // $* resolved dynamically via tsconfig.json compilerOptions.paths
8
+
9
+ let cachedTsconfigPaths: Record<string, string[]> | null = null;
10
+ async function getTsconfigPaths() {
11
+ if (cachedTsconfigPaths !== null) return cachedTsconfigPaths;
12
+ try {
13
+ const tsconfig = await Bun.file(join(process.cwd(), "tsconfig.json")).json();
14
+ cachedTsconfigPaths = tsconfig?.compilerOptions?.paths || {};
15
+ } catch {
16
+ cachedTsconfigPaths = {};
17
+ }
18
+ return cachedTsconfigPaths!;
19
+ }
8
20
 
9
21
  export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
10
22
  return {
@@ -24,11 +36,37 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
24
36
  ),
25
37
  }));
26
38
 
27
- // $lib/* src/lib/* with extension probing
28
- build.onResolve({ filter: /^\$lib\// }, async (args) => {
29
- const rel = args.path.slice(5); // remove "$lib/"
30
- const base = join(process.cwd(), "src", "lib", rel);
31
- return { path: await resolveWithExts(base) };
39
+ // Handle all $ aliases using tsconfig.json paths (e.g. $lib, $registry)
40
+ build.onResolve({ filter: /^\$/ }, async (args) => {
41
+ if (args.path === "$env") return undefined; // Handled above
42
+
43
+ const paths = await getTsconfigPaths();
44
+ let longestMatch = "";
45
+ let targetPattern = "";
46
+
47
+ for (const [pattern, targets] of Object.entries(paths)) {
48
+ const prefix = pattern.replace(/\*$/, "");
49
+ if (args.path.startsWith(prefix) && prefix.length > longestMatch.length) {
50
+ longestMatch = prefix;
51
+ targetPattern = (targets as string[])[0];
52
+ }
53
+ }
54
+
55
+ if (longestMatch && targetPattern) {
56
+ const suffix = args.path.slice(longestMatch.length);
57
+ const targetDir = targetPattern.replace(/\*$/, "");
58
+ const resolved = join(process.cwd(), targetDir, suffix);
59
+ return { path: await resolveWithExts(resolved) };
60
+ }
61
+
62
+ // Fallback for $lib/* if not in tsconfig
63
+ if (args.path.startsWith("$lib/")) {
64
+ const rel = args.path.slice(5);
65
+ const base = join(process.cwd(), "src", "lib", rel);
66
+ return { path: await resolveWithExts(base) };
67
+ }
68
+
69
+ return undefined;
32
70
  });
33
71
 
34
72
  // Force svelte imports to resolve from the app's node_modules.
@@ -1,4 +1,4 @@
1
- import { writeFileSync, mkdirSync } from "fs";
1
+ import { writeFileSync, mkdirSync, cpSync, existsSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { RouteManifest } from "./types.ts";
4
4
 
@@ -14,13 +14,33 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<string[]>
14
14
  const paths: string[] = [];
15
15
  for (const route of manifest.pages) {
16
16
  if (!route.pageServer) continue;
17
+ const filePath = join("src", "routes", route.pageServer);
18
+ const content = await Bun.file(filePath).text();
19
+ if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) continue;
20
+
17
21
  if (route.pattern.includes("[")) {
18
- // TODO: Support dynamic routes by reading export const entries() and calling it to get param values
19
- // Then prerender each route variant: /blog/slug1, /blog/slug2, etc.
20
- continue;
21
- }
22
- const content = await Bun.file(join("src", "routes", route.pageServer)).text();
23
- if (/export\s+const\s+prerender\s*=\s*true/.test(content)) {
22
+ // Dynamic route import module and call entries() to get param values
23
+ try {
24
+ const mod = await import(join(process.cwd(), filePath));
25
+ if (typeof mod.entries !== "function") {
26
+ console.warn(` ⚠️ ${route.pattern} has prerender=true but no entries() export — skipped`);
27
+ continue;
28
+ }
29
+ const entryList: Record<string, string>[] = await mod.entries();
30
+ for (const entry of entryList) {
31
+ let resolved = route.pattern;
32
+ for (const [key, value] of Object.entries(entry)) {
33
+ // [...slug] → value (rest param)
34
+ resolved = resolved.replace(`[...${key}]`, value);
35
+ // [param] → value
36
+ resolved = resolved.replace(`[${key}]`, value);
37
+ }
38
+ paths.push(resolved);
39
+ }
40
+ } catch (err) {
41
+ console.error(` ❌ Failed to resolve entries() for ${route.pattern}:`, err);
42
+ }
43
+ } else {
24
44
  paths.push(route.pattern);
25
45
  }
26
46
  }
@@ -71,7 +91,19 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
71
91
  : `./dist/prerendered${routePath}/index.html`;
72
92
  mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
73
93
  writeFileSync(outPath, html);
74
- console.log(` ✅ ${routePath} → ${outPath}`);
94
+
95
+ // Also prerender the data payload
96
+ const dataPath = routePath === "/" ? "/index.json" : `${routePath.replace(/\/$/, "")}.json`;
97
+ const dataRes = await fetch(`${base}/__bosia/data${dataPath}`, { signal: AbortSignal.timeout(PRERENDER_TIMEOUT) });
98
+ if (dataRes.ok) {
99
+ const dataJson = await dataRes.text();
100
+ const dataOutPath = `./dist/prerendered/__bosia/data${dataPath}`;
101
+ mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), { recursive: true });
102
+ writeFileSync(dataOutPath, dataJson);
103
+ console.log(` ✅ ${routePath} → ${outPath} (+ data)`);
104
+ } else {
105
+ console.log(` ✅ ${routePath} → ${outPath}`);
106
+ }
75
107
  } catch (err) {
76
108
  if (err instanceof DOMException && err.name === "TimeoutError") {
77
109
  console.error(` ❌ Prerender timed out for ${routePath} after ${PRERENDER_TIMEOUT / 1000}s — increase PRERENDER_TIMEOUT to raise the limit`);
@@ -84,3 +116,28 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
84
116
  child.kill();
85
117
  console.log("✅ Prerendering complete");
86
118
  }
119
+
120
+ // ─── Static Site Output ──────────────────────────────────
121
+
122
+ export function generateStaticSite(): void {
123
+ if (!existsSync("./dist/prerendered")) {
124
+ console.log("\n⏭️ No prerendered pages — skipping static site output");
125
+ return;
126
+ }
127
+
128
+ console.log("\n📦 Generating static site...");
129
+ mkdirSync("./dist/static", { recursive: true });
130
+
131
+ // 1. HTML files from prerendering
132
+ cpSync("./dist/prerendered", "./dist/static", { recursive: true });
133
+
134
+ // 2. Client JS/CSS — preserves /dist/client/... absolute paths used in HTML
135
+ cpSync("./dist/client", "./dist/static/dist/client", { recursive: true });
136
+
137
+ // 3. Public assets (bosia-tw.css, favicon, etc.) — preserves /bosia-tw.css path
138
+ if (existsSync("./public")) {
139
+ cpSync("./public", "./dist/static", { recursive: true });
140
+ }
141
+
142
+ console.log("✅ Static site generated: dist/static/");
143
+ }
@@ -5,7 +5,7 @@ import { serverRoutes, errorPage } from "bosia:routes";
5
5
  import type { Cookies } from "./hooks.ts";
6
6
  import { HttpError, Redirect } from "./errors.ts";
7
7
  import App from "./client/App.svelte";
8
- import { buildHtml, buildHtmlShellOpen, buildMetadataChunk, buildHtmlTail, compress, safeJsonStringify, isDev } from "./html.ts";
8
+ import { buildHtml, buildHtmlShellOpen, buildMetadataChunk, buildHtmlTail, compress, isDev } from "./html.ts";
9
9
  import type { Metadata } from "./hooks.ts";
10
10
 
11
11
  // ─── Timeout Helpers ─────────────────────────────────────
@@ -92,6 +92,7 @@ export async function loadRouteData(
92
92
  if (err instanceof HttpError || err instanceof Redirect) throw err;
93
93
  if (isDev) console.error("Layout server load error:", err);
94
94
  else console.error("Layout server load error:", (err as Error).message ?? err);
95
+ throw new HttpError(500, "Internal Server Error");
95
96
  }
96
97
  }
97
98
 
@@ -114,6 +115,7 @@ export async function loadRouteData(
114
115
  if (err instanceof HttpError || err instanceof Redirect) throw err;
115
116
  if (isDev) console.error("Page server load error:", err);
116
117
  else console.error("Page server load error:", (err as Error).message ?? err);
118
+ throw new HttpError(500, "Internal Server Error");
117
119
  }
118
120
  }
119
121
 
@@ -122,7 +124,7 @@ export async function loadRouteData(
122
124
 
123
125
  // ─── Metadata Loader ─────────────────────────────────────
124
126
 
125
- async function loadMetadata(
127
+ export async function loadMetadata(
126
128
  route: any,
127
129
  params: Record<string, string>,
128
130
  url: URL,
@@ -174,9 +176,28 @@ export async function renderSSRStream(
174
176
  // Continue with null metadata — don't break the page for a metadata failure
175
177
  }
176
178
 
177
- // Kick off imports immediately (parallel with data loading)
178
- const pageModPromise = route.pageModule();
179
- const layoutModsPromise = Promise.all(route.layoutModules.map((l: () => Promise<any>) => l()));
179
+ // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
180
+ // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
181
+ const metadataData = metadata?.data ?? null;
182
+ let data: Awaited<ReturnType<typeof loadRouteData>>;
183
+ let pageMod: any;
184
+ let layoutMods: any[];
185
+
186
+ try {
187
+ [data, pageMod, layoutMods] = await Promise.all([
188
+ loadRouteData(url, locals, req, cookies, metadataData),
189
+ route.pageModule(),
190
+ Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
191
+ ]);
192
+ } catch (err) {
193
+ if (err instanceof Redirect) return Response.redirect(err.location, err.status);
194
+ if (err instanceof HttpError) return renderErrorPage(err.status, err.message, url, req);
195
+ if (isDev) console.error("SSR load error:", err);
196
+ else console.error("SSR load error:", (err as Error).message ?? err);
197
+ return renderErrorPage(500, "Internal Server Error", url, req);
198
+ }
199
+
200
+ if (!data) return renderErrorPage(404, "Not Found", url, req);
180
201
 
181
202
  const enc = new TextEncoder();
182
203
 
@@ -189,53 +210,23 @@ export async function renderSSRStream(
189
210
  controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
190
211
 
191
212
  try {
192
- // Pass metadata.data to load() so it can reuse fetched data
193
- const metadataData = metadata?.data ?? null;
194
-
195
- // Wait for data + component imports
196
- const [data, pageMod, layoutMods] = await Promise.all([
197
- loadRouteData(url, locals, req, cookies, metadataData),
198
- pageModPromise,
199
- layoutModsPromise,
200
- ]);
201
-
202
- if (!data) {
203
- controller.enqueue(enc.encode(`</body></html>`));
204
- controller.close();
205
- return;
206
- }
207
-
208
213
  const { body, head } = render(App, {
209
214
  props: {
210
215
  ssrMode: true,
211
216
  ssrPageComponent: pageMod.default,
212
217
  ssrLayoutComponents: layoutMods.map((m: any) => m.default),
213
- ssrPageData: data.pageData,
214
- ssrLayoutData: data.layoutData,
218
+ ssrPageData: data!.pageData,
219
+ ssrLayoutData: data!.layoutData,
215
220
  },
216
221
  });
217
222
 
218
223
  // Chunk 3: rendered content
219
- controller.enqueue(enc.encode(buildHtmlTail(body, head, data.pageData, data.layoutData, data.csr)));
224
+ controller.enqueue(enc.encode(buildHtmlTail(body, head, data!.pageData, data!.layoutData, data!.csr)));
220
225
  controller.close();
221
226
  } catch (err) {
222
- // Head is closed and body is open at this point — HTML structure is valid
223
- if (err instanceof Redirect) {
224
- controller.enqueue(enc.encode(
225
- `<script>location.replace(${safeJsonStringify(err.location)})</script></body></html>`
226
- ));
227
- controller.close();
228
- return;
229
- }
230
- if (err instanceof HttpError) {
231
- controller.enqueue(enc.encode(
232
- `<script>location.replace("/__bosia/error?status=${err.status}&message="+encodeURIComponent(${safeJsonStringify(err.message)}))</script></body></html>`
233
- ));
234
- controller.close();
235
- return;
236
- }
237
- if (isDev) console.error("SSR stream error:", err);
238
- else console.error("SSR stream error:", (err as Error).message ?? err);
227
+ // Only render() can throw here data is already loaded successfully
228
+ if (isDev) console.error("SSR render error:", err);
229
+ else console.error("SSR render error:", (err as Error).message ?? err);
239
230
  controller.enqueue(enc.encode(`<p>Internal Server Error</p></body></html>`));
240
231
  controller.close();
241
232
  }
@@ -294,14 +285,11 @@ export async function renderErrorPage(status: number, message: string, url: URL,
294
285
  if (errorPage) {
295
286
  try {
296
287
  const mod = await errorPage();
297
- const { body, head } = render(App, {
298
- props: {
299
- ssrMode: true,
300
- ssrPageComponent: mod.default,
301
- ssrLayoutComponents: [],
302
- ssrPageData: { status, message },
303
- ssrLayoutData: [],
304
- },
288
+ // Render the error component directly — NOT through App.svelte.
289
+ // App.svelte always remaps ssrPageData to a `data` prop, but +error.svelte
290
+ // expects `error` as a direct prop: `let { error } = $props()`.
291
+ const { body, head } = render(mod.default, {
292
+ props: { error: { status, message } },
305
293
  });
306
294
  const html = buildHtml(body, head, { status, message }, [], false);
307
295
  return compress(html, "text/html; charset=utf-8", req, status);
@@ -101,7 +101,9 @@ export function generateRoutesFile(manifest: RouteManifest): void {
101
101
 
102
102
  mkdirSync(".bosia", { recursive: true });
103
103
  writeFileSync(".bosia/routes.ts", lines.join("\n"));
104
- console.log("✅ Routes generated: .bosia/routes.ts");
104
+ const pagePatterns = pages.map(p => p.pattern).join(", ") || "(none)";
105
+ console.log(`✅ Routes generated: .bosia/routes.ts`);
106
+ console.log(` Found ${pages.length} page route(s): ${pagePatterns}`);
105
107
  }
106
108
 
107
109
  // Import path from .bosia/routes.ts to src/routes/<routePath>
@@ -88,6 +88,24 @@ export function scanRoutes(): RouteManifest {
88
88
 
89
89
  walk("", [], [], []);
90
90
 
91
+ // Warn when a catch-all exists but no exact route covers its prefix.
92
+ // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
93
+ const exactPatterns = new Set(
94
+ pages.filter(p => !p.pattern.includes("[")).map(p => p.pattern),
95
+ );
96
+ for (const p of pages) {
97
+ const m = p.pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
98
+ if (m) {
99
+ const exactEquivalent = m[1] || "/";
100
+ if (!exactPatterns.has(exactEquivalent)) {
101
+ console.warn(
102
+ `⚠️ No exact route for "${exactEquivalent}" — the catch-all "${p.pattern}" will NOT match it.\n` +
103
+ ` Add a +page.svelte at the "${exactEquivalent}" level to serve that URL.`,
104
+ );
105
+ }
106
+ }
107
+ }
108
+
91
109
  const errorPage = existsSync(join(ROUTES_DIR, "+error.svelte")) ? "+error.svelte" : null;
92
110
 
93
111
  return { pages, apis, errorPage };
@@ -13,7 +13,7 @@ import type { CsrfConfig } from "./csrf.ts";
13
13
  import { getCorsHeaders, handlePreflight } from "./cors.ts";
14
14
  import type { CorsConfig } from "./cors.ts";
15
15
  import { isDev, compress, isStaticPath } from "./html.ts";
16
- import { loadRouteData, renderSSRStream, renderErrorPage, renderPageWithFormData } from "./renderer.ts";
16
+ import { loadRouteData, loadMetadata, renderSSRStream, renderErrorPage, renderPageWithFormData } from "./renderer.ts";
17
17
  import { getServerTime } from "../lib/utils.ts";
18
18
 
19
19
  // ─── User Hooks ──────────────────────────────────────────
@@ -113,18 +113,33 @@ async function resolve(event: RequestEvent): Promise<Response> {
113
113
  }
114
114
 
115
115
  // Data endpoint — returns server loader data as JSON for client-side navigation
116
- if (path === "/__bosia/data") {
117
- const routePath = url.searchParams.get("path") ?? "/";
118
- if (!isValidRoutePath(routePath, url.origin)) {
116
+ if (path.startsWith("/__bosia/data/")) {
117
+ const routePathStr = path.slice("/__bosia/data".length).replace(/\.json$/, "").replace(/^\/index$/, "/") || "/";
118
+
119
+ if (!isValidRoutePath(routePathStr, url.origin)) {
119
120
  return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
120
121
  }
121
- const routeUrl = new URL(routePath, url.origin);
122
+ const routeUrl = new URL(routePathStr, url.origin);
123
+ for (const [key, val] of url.searchParams.entries()) {
124
+ routeUrl.searchParams.append(key, val);
125
+ }
122
126
  // Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
123
127
  event.url = routeUrl;
124
128
  try {
129
+ const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
125
130
  const data = await loadRouteData(routeUrl, locals, request, cookies);
126
131
  if (!data) return compress(JSON.stringify({ pageData: {}, layoutData: [] }), "application/json", request);
127
- return compress(JSON.stringify(data), "application/json", request);
132
+
133
+ // Include metadata for client-side title/description updates
134
+ let metadata = null;
135
+ if (pageMatch) {
136
+ try {
137
+ const meta = await loadMetadata(pageMatch.route, pageMatch.params, routeUrl, locals, cookies, request);
138
+ if (meta) metadata = { title: meta.title, description: meta.description };
139
+ } catch { /* non-fatal */ }
140
+ }
141
+
142
+ return compress(JSON.stringify({ ...data, metadata }), "application/json", request);
128
143
  } catch (err) {
129
144
  if (err instanceof Redirect) {
130
145
  return compress(JSON.stringify({ redirect: err.location, status: err.status }), "application/json", request);