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 +2 -1
- package/src/cli/add.ts +110 -14
- package/src/cli/create.ts +14 -19
- package/src/cli/index.ts +5 -2
- package/src/core/build.ts +5 -2
- package/src/core/client/App.svelte +14 -0
- package/src/core/html.ts +2 -0
- package/src/core/matcher.ts +3 -0
- package/src/core/prerender.ts +52 -7
- package/src/core/renderer.ts +37 -49
- package/src/core/routeFile.ts +3 -1
- package/src/core/scanner.ts +18 -0
- package/src/core/server.ts +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
7
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
//
|
|
44
|
-
const destDir = join(process.cwd(), "src", "lib", "components",
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
3
|
import { spawn } from "bun";
|
|
4
|
-
import * as
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
91
|
+
if (p.isCancel(selected)) {
|
|
92
|
+
p.cancel("Operation cancelled.");
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
94
95
|
|
|
95
|
-
return
|
|
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
|
-
|
|
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")) ??
|
|
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
|
}
|
package/src/core/matcher.ts
CHANGED
|
@@ -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;
|
package/src/core/prerender.ts
CHANGED
|
@@ -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
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
178
|
-
|
|
179
|
-
const
|
|
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
|
|
214
|
-
ssrLayoutData: data
|
|
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
|
|
224
|
+
controller.enqueue(enc.encode(buildHtmlTail(body, head, data!.pageData, data!.layoutData, data!.csr)));
|
|
220
225
|
controller.close();
|
|
221
226
|
} catch (err) {
|
|
222
|
-
//
|
|
223
|
-
if (
|
|
224
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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);
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/src/core/scanner.ts
CHANGED
|
@@ -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 };
|
package/src/core/server.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|