bosia 0.1.0 → 0.1.2-rc.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.1.0",
3
+ "version": "0.1.2-rc.1",
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": [
@@ -44,6 +44,7 @@
44
44
  "typescript": "^5"
45
45
  },
46
46
  "dependencies": {
47
+ "@clack/prompts": "^1.1.0",
47
48
  "@tailwindcss/cli": "^4.2.1",
48
49
  "bun-plugin-svelte": "^0.0.6",
49
50
  "clsx": "^2.1.1",
package/src/cli/add.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import { join, dirname } from "path";
2
- import { mkdirSync, writeFileSync } from "fs";
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
- // Fetches a component from the GitHub registry and copies it
7
- // into the user's src/lib/components/ui/<name>/ directory.
7
+ // Fetches a component from the GitHub registry (or local registry
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
- const REGISTRY_BASE = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
14
+ const REMOTE_BASE = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
10
15
 
11
16
  interface ComponentMeta {
12
17
  name: string;
@@ -19,37 +24,71 @@ interface ComponentMeta {
19
24
  // Track already-installed components within a session to avoid re-running deps
20
25
  const installed = new Set<string>();
21
26
 
22
- export async function runAdd(name: string | undefined) {
27
+ // Resolved once in runAdd, used by addComponent
28
+ let registryRoot: string | null = null;
29
+
30
+ export async function runAdd(name: string | undefined, flags: string[] = []) {
23
31
  if (!name) {
24
- console.error("❌ Please provide a component name.\n Usage: bosia add <component>");
32
+ console.error("❌ Please provide a component name.\n Usage: bosia add <component> [--local]");
25
33
  process.exit(1);
26
34
  }
35
+
36
+ if (flags.includes("--local")) {
37
+ // Walk up from this file to find the repo's registry/ directory
38
+ registryRoot = resolveLocalRegistry();
39
+ console.log(`⬡ Using local registry: ${registryRoot}\n`);
40
+ }
41
+
42
+ ensureUtils();
27
43
  await addComponent(name, true);
28
44
  }
29
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
+
30
55
  export async function addComponent(name: string, root = false) {
31
- if (installed.has(name)) return;
32
- 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);
33
61
 
34
62
  console.log(root ? `⬡ Installing component: ${name}\n` : ` 📦 Dependency: ${name}`);
35
63
 
36
- const meta = await fetchJSON<ComponentMeta>(`${REGISTRY_BASE}/components/${name}/meta.json`);
64
+ const meta = await readMeta(fullPath);
37
65
 
38
66
  // Install component dependencies first (recursive)
39
67
  for (const dep of meta.dependencies) {
40
68
  await addComponent(dep, false);
41
69
  }
42
70
 
43
- // Download component files into src/lib/components/ui/<name>/
44
- 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>/
45
84
  mkdirSync(destDir, { recursive: true });
46
85
 
47
86
  for (const file of meta.files) {
48
- const content = await fetchText(`${REGISTRY_BASE}/components/${name}/${file}`);
87
+ const content = await readFile(fullPath, file);
49
88
  const dest = join(destDir, file);
50
89
  mkdirSync(dirname(dest), { recursive: true });
51
90
  writeFileSync(dest, content, "utf-8");
52
- console.log(` ✍️ src/lib/components/ui/${name}/${file}`);
91
+ console.log(` ✍️ src/lib/components/${fullPath}/${file}`);
53
92
  }
54
93
 
55
94
  // Install npm dependencies
@@ -67,7 +106,64 @@ export async function addComponent(name: string, root = false) {
67
106
  }
68
107
  }
69
108
 
70
- 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}/`);
110
+ }
111
+
112
+ // ─── Ensure $lib/utils.ts exists ─────────────────────────────
113
+
114
+ const UTILS_CONTENT = `import { clsx, type ClassValue } from "clsx";
115
+ import { twMerge } from "tailwind-merge";
116
+
117
+ export function cn(...inputs: ClassValue[]) {
118
+ return twMerge(clsx(inputs));
119
+ }
120
+ `;
121
+
122
+ function ensureUtils() {
123
+ const utilsPath = join(process.cwd(), "src", "lib", "utils.ts");
124
+ if (!existsSync(utilsPath)) {
125
+ mkdirSync(dirname(utilsPath), { recursive: true });
126
+ writeFileSync(utilsPath, UTILS_CONTENT, "utf-8");
127
+ console.log(" ✍️ src/lib/utils.ts (cn utility)\n");
128
+ }
129
+ }
130
+
131
+ // ─── Registry resolvers ──────────────────────────────────────
132
+
133
+ function resolveLocalRegistry(): string {
134
+ // Walk up from this file's directory to find registry/
135
+ let dir = dirname(new URL(import.meta.url).pathname);
136
+ for (let i = 0; i < 10; i++) {
137
+ const candidate = join(dir, "registry");
138
+ if (existsSync(join(candidate, "index.json"))) return candidate;
139
+ const parent = dirname(dir);
140
+ if (parent === dir) break;
141
+ dir = parent;
142
+ }
143
+ console.error("❌ Could not find local registry/ directory.");
144
+ process.exit(1);
145
+ }
146
+
147
+ async function readMeta(name: string): Promise<ComponentMeta> {
148
+ if (registryRoot) {
149
+ const path = join(registryRoot, "components", name, "meta.json");
150
+ if (!existsSync(path)) {
151
+ throw new Error(`Component "${name}" not found in local registry`);
152
+ }
153
+ return JSON.parse(readFileSync(path, "utf-8"));
154
+ }
155
+ return fetchJSON<ComponentMeta>(`${REMOTE_BASE}/components/${name}/meta.json`);
156
+ }
157
+
158
+ async function readFile(name: string, file: string): Promise<string> {
159
+ if (registryRoot) {
160
+ const path = join(registryRoot, "components", name, file);
161
+ if (!existsSync(path)) {
162
+ throw new Error(`File "${file}" not found for component "${name}" in local registry`);
163
+ }
164
+ return readFileSync(path, "utf-8");
165
+ }
166
+ return fetchText(`${REMOTE_BASE}/components/${name}/${file}`);
71
167
  }
72
168
 
73
169
  async function fetchJSON<T>(url: string): Promise<T> {
package/src/cli/create.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { resolve, join, basename } from "path";
2
- import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from "fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { spawn } from "bun";
4
- import * as readline from "readline";
4
+ import * as p from "@clack/prompts";
5
5
 
6
6
  // ─── bosia create <name> [--template <name>] ──────────────
7
7
 
@@ -79,26 +79,21 @@ async function promptTemplate(): Promise<string> {
79
79
 
80
80
  if (templates.length === 1) return templates[0];
81
81
 
82
- console.log("\n? Which template?\n");
83
- templates.forEach((t, i) => {
84
- const desc = TEMPLATE_DESCRIPTIONS[t] ?? "";
85
- const marker = i === 0 ? "❯" : " ";
86
- console.log(` ${marker} ${t}${desc ? ` — ${desc}` : ""}`);
82
+ const selected = await p.select({
83
+ message: "Which template?",
84
+ options: templates.map((t) => ({
85
+ value: t,
86
+ label: t,
87
+ hint: TEMPLATE_DESCRIPTIONS[t],
88
+ })),
87
89
  });
88
- console.log();
89
90
 
90
- const rl = readline.createInterface({
91
- input: process.stdin,
92
- output: process.stdout,
93
- });
91
+ if (p.isCancel(selected)) {
92
+ p.cancel("Operation cancelled.");
93
+ process.exit(0);
94
+ }
94
95
 
95
- return new Promise<string>((resolvePromise) => {
96
- rl.question(` Template name (default): `, (answer) => {
97
- rl.close();
98
- const trimmed = answer.trim();
99
- resolvePromise(trimmed || "default");
100
- });
101
- });
96
+ return selected as string;
102
97
  }
103
98
 
104
99
  function copyDir(src: string, dest: string, projectName: string) {
package/src/cli/index.ts CHANGED
@@ -33,7 +33,9 @@ async function main() {
33
33
  }
34
34
  case "add": {
35
35
  const { runAdd } = await import("./add.ts");
36
- await runAdd(args[0]);
36
+ const addName = args.find((a) => !a.startsWith("--"));
37
+ const addFlags = args.filter((a) => a.startsWith("--"));
38
+ await runAdd(addName, addFlags);
37
39
  break;
38
40
  }
39
41
  case "feat": {
@@ -62,7 +64,8 @@ Examples:
62
64
  bosia dev
63
65
  bosia build
64
66
  bosia start
65
- bosia add button
67
+ bosia add button → src/lib/components/ui/button/
68
+ bosia add shop/cart → src/lib/components/shop/cart/
66
69
  bosia feat login
67
70
  `);
68
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!");
@@ -84,6 +84,20 @@
84
84
  pageData = result?.pageData ?? {};
85
85
  layoutData = result?.layoutData ?? [];
86
86
  routeParams = result?.pageData?.params ?? match.params;
87
+
88
+ // Update document title and meta description from server metadata
89
+ if (result?.metadata) {
90
+ if (result.metadata.title) document.title = result.metadata.title;
91
+ if (result.metadata.description) {
92
+ let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
93
+ if (!meta) {
94
+ meta = document.createElement("meta");
95
+ meta.name = "description";
96
+ document.head.appendChild(meta);
97
+ }
98
+ meta.content = result.metadata.description;
99
+ }
100
+ }
87
101
  });
88
102
 
89
103
  return () => { cancelled = true; };
package/src/core/html.ts CHANGED
@@ -96,6 +96,7 @@ 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
101
  <body data-bosia-preload="hover">
101
102
  <div id="app">${body}</div>${scripts}
@@ -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
  }
@@ -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;
@@ -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
  }
@@ -84,3 +104,28 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
84
104
  child.kill();
85
105
  console.log("✅ Prerendering complete");
86
106
  }
107
+
108
+ // ─── Static Site Output ──────────────────────────────────
109
+
110
+ export function generateStaticSite(): void {
111
+ if (!existsSync("./dist/prerendered")) {
112
+ console.log("\n⏭️ No prerendered pages — skipping static site output");
113
+ return;
114
+ }
115
+
116
+ console.log("\n📦 Generating static site...");
117
+ mkdirSync("./dist/static", { recursive: true });
118
+
119
+ // 1. HTML files from prerendering
120
+ cpSync("./dist/prerendered", "./dist/static", { recursive: true });
121
+
122
+ // 2. Client JS/CSS — preserves /dist/client/... absolute paths used in HTML
123
+ cpSync("./dist/client", "./dist/static/dist/client", { recursive: true });
124
+
125
+ // 3. Public assets (bosia-tw.css, favicon, etc.) — preserves /bosia-tw.css path
126
+ if (existsSync("./public")) {
127
+ cpSync("./public", "./dist/static", { recursive: true });
128
+ }
129
+
130
+ console.log("✅ Static site generated: dist/static/");
131
+ }
@@ -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 ──────────────────────────────────────────
@@ -122,9 +122,20 @@ async function resolve(event: RequestEvent): Promise<Response> {
122
122
  // Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
123
123
  event.url = routeUrl;
124
124
  try {
125
+ const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
125
126
  const data = await loadRouteData(routeUrl, locals, request, cookies);
126
127
  if (!data) return compress(JSON.stringify({ pageData: {}, layoutData: [] }), "application/json", request);
127
- return compress(JSON.stringify(data), "application/json", request);
128
+
129
+ // Include metadata for client-side title/description updates
130
+ let metadata = null;
131
+ if (pageMatch) {
132
+ try {
133
+ const meta = await loadMetadata(pageMatch.route, pageMatch.params, routeUrl, locals, cookies, request);
134
+ if (meta) metadata = { title: meta.title, description: meta.description };
135
+ } catch { /* non-fatal */ }
136
+ }
137
+
138
+ return compress(JSON.stringify({ ...data, metadata }), "application/json", request);
128
139
  } catch (err) {
129
140
  if (err instanceof Redirect) {
130
141
  return compress(JSON.stringify({ redirect: err.location, status: err.status }), "application/json", request);