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 +1 -1
- package/src/cli/add.ts +37 -9
- package/src/cli/index.ts +2 -1
- package/src/core/build.ts +5 -2
- package/src/core/client/App.svelte +24 -9
- package/src/core/client/prefetch.ts +8 -1
- package/src/core/html.ts +4 -2
- package/src/core/matcher.ts +3 -0
- package/src/core/plugin.ts +44 -6
- package/src/core/prerender.ts +65 -8
- 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 +21 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.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
|
|
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
|
-
|
|
43
|
-
|
|
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(
|
|
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
|
-
//
|
|
55
|
-
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>/
|
|
56
84
|
mkdirSync(destDir, { recursive: true });
|
|
57
85
|
|
|
58
86
|
for (const file of meta.files) {
|
|
59
|
-
const content = await readFile(
|
|
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
|
|
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
|
|
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
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!");
|
|
@@ -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 (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
154
|
+
out += `</head>\n<body>\n${SPINNER}`;
|
|
153
155
|
return out;
|
|
154
156
|
}
|
|
155
157
|
|
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/plugin.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
// $
|
|
28
|
-
build.onResolve({ filter:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|
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 ──────────────────────────────────────────
|
|
@@ -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
|
|
117
|
-
const
|
|
118
|
-
|
|
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(
|
|
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
|
-
|
|
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);
|