bosia 0.6.8 → 0.6.10
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/index.ts +6 -0
- package/src/cli/sync.ts +16 -0
- package/src/core/client/App.svelte +15 -3
- package/src/core/client/router.svelte.ts +32 -0
- package/src/core/routeTypes.ts +2 -0
- package/src/core/server.ts +23 -6
- package/src/core/staticManifest.ts +106 -0
- package/src/lib/index.ts +2 -0
- package/templates/default/package.json +2 -1
- package/templates/default/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
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/index.ts
CHANGED
|
@@ -26,6 +26,11 @@ async function main() {
|
|
|
26
26
|
await runBuild();
|
|
27
27
|
break;
|
|
28
28
|
}
|
|
29
|
+
case "sync": {
|
|
30
|
+
const { runSync } = await import("./sync.ts");
|
|
31
|
+
await runSync();
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
29
34
|
case "start": {
|
|
30
35
|
const { runStart } = await import("./start.ts");
|
|
31
36
|
await runStart();
|
|
@@ -80,6 +85,7 @@ Commands:
|
|
|
80
85
|
create <name> [--template <t>] Scaffold a new Bosia project
|
|
81
86
|
dev Start the development server
|
|
82
87
|
build Build for production
|
|
88
|
+
sync Generate .bosia/ codegen (routes, $types, env) without building
|
|
83
89
|
start Run the production server
|
|
84
90
|
test [args] Run tests with bun test (auto-loads .env.test, sets BOSIA_ENV=test)
|
|
85
91
|
add <component...> [-y] Add one or more UI components from the registry
|
package/src/cli/sync.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { scanRoutes } from "../core/scanner.ts";
|
|
2
|
+
import { generateRoutesFile } from "../core/routeFile.ts";
|
|
3
|
+
import { generateRouteTypes, ensureRootDirs } from "../core/routeTypes.ts";
|
|
4
|
+
import { loadEnv, classifyEnvVars } from "../core/env.ts";
|
|
5
|
+
import { generateEnvModules } from "../core/envCodegen.ts";
|
|
6
|
+
|
|
7
|
+
export async function runSync() {
|
|
8
|
+
const envMode = process.env.NODE_ENV === "production" ? "production" : "development";
|
|
9
|
+
const classifiedEnv = classifyEnvVars(loadEnv(envMode));
|
|
10
|
+
const manifest = scanRoutes();
|
|
11
|
+
generateRoutesFile(manifest);
|
|
12
|
+
generateRouteTypes(manifest);
|
|
13
|
+
ensureRootDirs();
|
|
14
|
+
generateEnvModules(classifiedEnv);
|
|
15
|
+
console.log("✅ Bosia codegen ready (.bosia/routes.ts, types, env modules)");
|
|
16
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import { router, scrollToHash } from "./router.svelte.ts";
|
|
3
4
|
import { findMatch } from "../matcher.ts";
|
|
4
5
|
import { clientRoutes } from "bosia:routes";
|
|
5
6
|
import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
|
|
@@ -198,7 +199,12 @@
|
|
|
198
199
|
appState.errorComponent = errMod.default;
|
|
199
200
|
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
200
201
|
appState.errorDepth = K;
|
|
201
|
-
if (router.isPush && !router.suppressScroll)
|
|
202
|
+
if (router.isPush && !router.suppressScroll) {
|
|
203
|
+
const hash = window.location.hash;
|
|
204
|
+
tick().then(() => {
|
|
205
|
+
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
202
208
|
router.suppressScroll = false;
|
|
203
209
|
settle({ url, params: match.params });
|
|
204
210
|
} catch {
|
|
@@ -290,7 +296,13 @@
|
|
|
290
296
|
|
|
291
297
|
// Scroll to top on forward navigation (not on popstate/back-forward).
|
|
292
298
|
// goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
|
|
293
|
-
|
|
299
|
+
// If the destination URL has a hash, scroll to that element instead.
|
|
300
|
+
if (router.isPush && !router.suppressScroll) {
|
|
301
|
+
const hash = window.location.hash;
|
|
302
|
+
tick().then(() => {
|
|
303
|
+
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
294
306
|
router.suppressScroll = false;
|
|
295
307
|
|
|
296
308
|
// Update document title and meta description from server metadata
|
|
@@ -16,6 +16,22 @@ function buildTarget(path: string): { url: URL; params: Record<string, string> }
|
|
|
16
16
|
return { url, params: match?.params ?? {} };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function scrollToHash(hash: string): boolean {
|
|
20
|
+
if (typeof document === "undefined" || !hash) return false;
|
|
21
|
+
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
22
|
+
if (!raw) return false;
|
|
23
|
+
let id = raw;
|
|
24
|
+
try {
|
|
25
|
+
id = decodeURIComponent(raw);
|
|
26
|
+
} catch {
|
|
27
|
+
// Fallback to raw if URI sequence is malformed.
|
|
28
|
+
}
|
|
29
|
+
const el = document.getElementById(id) ?? document.getElementById(raw);
|
|
30
|
+
if (!el) return false;
|
|
31
|
+
el.scrollIntoView();
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
export const router = new (class Router {
|
|
20
36
|
currentRoute = $state(
|
|
21
37
|
typeof window !== "undefined"
|
|
@@ -89,6 +105,22 @@ export const router = new (class Router {
|
|
|
89
105
|
if (anchor.rel.split(/\s+/).includes("external")) return;
|
|
90
106
|
if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
|
|
91
107
|
|
|
108
|
+
// Same-page hash navigation: skip page reload, just update URL and scroll
|
|
109
|
+
// to the target element. Mirrors browser default for in-page anchors.
|
|
110
|
+
const samePage =
|
|
111
|
+
anchor.pathname === window.location.pathname &&
|
|
112
|
+
anchor.search === window.location.search;
|
|
113
|
+
if (samePage && anchor.hash) {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
const finalPath = anchor.pathname + anchor.search + anchor.hash;
|
|
116
|
+
if (this.currentRoute !== finalPath) {
|
|
117
|
+
history.pushState({}, "", finalPath);
|
|
118
|
+
this.currentRoute = finalPath;
|
|
119
|
+
}
|
|
120
|
+
scrollToHash(anchor.hash);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
e.preventDefault();
|
|
93
125
|
this.navigate(anchor.pathname + anchor.search + anchor.hash, { source: "link" });
|
|
94
126
|
});
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -100,6 +100,7 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
100
100
|
`export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
|
|
101
101
|
);
|
|
102
102
|
lines.push(`export type Action = (event: _RequestEvent) => any;`);
|
|
103
|
+
lines.push(`export type Actions = Record<string, Action>;`);
|
|
103
104
|
lines.push(`export type PageData = Awaited<ReturnType<typeof _pageLoad>>;`);
|
|
104
105
|
} else {
|
|
105
106
|
lines.push(``);
|
|
@@ -107,6 +108,7 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
107
108
|
`export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
|
|
108
109
|
);
|
|
109
110
|
lines.push(`export type Action = (event: _RequestEvent) => any;`);
|
|
111
|
+
lines.push(`export type Actions = Record<string, Action>;`);
|
|
110
112
|
lines.push(`export type PageData = {};`);
|
|
111
113
|
}
|
|
112
114
|
lines.push(`export type PageProps = { data: PageData; params: Params };`);
|
package/src/core/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } f
|
|
|
24
24
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
25
25
|
import { dev500WithPlugins } from "./dev-500.ts";
|
|
26
26
|
import { OUT_DIR } from "./paths.ts";
|
|
27
|
+
import { buildStaticManifest, lookupStatic } from "./staticManifest.ts";
|
|
27
28
|
import { dedup, dedupKey } from "./dedup.ts";
|
|
28
29
|
import {
|
|
29
30
|
CACHE_ENABLED,
|
|
@@ -170,6 +171,12 @@ function parseActionName(url: URL): string {
|
|
|
170
171
|
return "default";
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
// Prod: walk `dist/client`, `./public`, and `OUT_DIR` once at boot so static-asset
|
|
175
|
+
// requests cost a single Map lookup instead of up to 4 `Bun.file().exists()` syscalls.
|
|
176
|
+
// Dev keeps the per-request fallthrough so files dropped into `public/` mid-session
|
|
177
|
+
// are served without a restart (dev's watcher doesn't fire on `public/`).
|
|
178
|
+
const staticManifest = isDev ? null : buildStaticManifest(OUT_DIR);
|
|
179
|
+
|
|
173
180
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
174
181
|
const { request, url, locals, cookies } = event;
|
|
175
182
|
const path = url.pathname;
|
|
@@ -327,7 +334,21 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
327
334
|
|
|
328
335
|
// Static files
|
|
329
336
|
if (isStaticPath(path)) {
|
|
330
|
-
//
|
|
337
|
+
// Prod fast path: single Map lookup, no per-request stat calls.
|
|
338
|
+
if (staticManifest) {
|
|
339
|
+
const hit = lookupStatic(staticManifest, path);
|
|
340
|
+
if (hit) {
|
|
341
|
+
return new Response(
|
|
342
|
+
Bun.file(hit.absPath),
|
|
343
|
+
hit.cacheControl
|
|
344
|
+
? { headers: { "Cache-Control": hit.cacheControl } }
|
|
345
|
+
: undefined,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return new Response("Not Found", { status: 404 });
|
|
349
|
+
}
|
|
350
|
+
// Dev: keep the per-request fallthrough so files dropped into `public/`
|
|
351
|
+
// mid-session are served without a restart.
|
|
331
352
|
if (path.startsWith("/dist/client/")) {
|
|
332
353
|
const resolved = safePath(
|
|
333
354
|
`${OUT_DIR}/client`,
|
|
@@ -336,11 +357,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
336
357
|
if (resolved) {
|
|
337
358
|
const file = Bun.file(resolved);
|
|
338
359
|
if (await file.exists()) {
|
|
339
|
-
|
|
340
|
-
const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
|
|
341
|
-
const cacheControl =
|
|
342
|
-
!isDev && isHashed ? "public, max-age=31536000, immutable" : "no-cache";
|
|
343
|
-
return new Response(file, { headers: { "Cache-Control": cacheControl } });
|
|
360
|
+
return new Response(file, { headers: { "Cache-Control": "no-cache" } });
|
|
344
361
|
}
|
|
345
362
|
}
|
|
346
363
|
return new Response("Not Found", { status: 404 });
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import { basename, join, resolve as resolvePath } from "path";
|
|
3
|
+
|
|
4
|
+
export type StaticEntry = { absPath: string; cacheControl?: string };
|
|
5
|
+
export type StaticManifest = Map<string, StaticEntry>;
|
|
6
|
+
|
|
7
|
+
const HASHED_BASENAME = /\-[a-z0-9]{8,}\.[a-z]+$/;
|
|
8
|
+
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
9
|
+
const DEFAULT_CACHE = "no-cache";
|
|
10
|
+
|
|
11
|
+
// Files/dirs at OUT_DIR root that the manifest must not surface — they're either
|
|
12
|
+
// build metadata or re-merges already covered by the per-root walks.
|
|
13
|
+
const OUT_DIR_SKIP_DIRS = new Set(["client", "static", "prerendered", "server"]);
|
|
14
|
+
const OUT_DIR_SKIP_FILES = new Set(["manifest.json", "route-manifest.json"]);
|
|
15
|
+
|
|
16
|
+
const RESERVED_PREFIX = "/__bosia/";
|
|
17
|
+
|
|
18
|
+
function* walk(dir: string, rel = ""): Generator<{ abs: string; rel: string }> {
|
|
19
|
+
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
20
|
+
try {
|
|
21
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" }) as unknown as Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
isDirectory(): boolean;
|
|
24
|
+
isFile(): boolean;
|
|
25
|
+
}>;
|
|
26
|
+
} catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const ent of entries) {
|
|
30
|
+
const childAbs = join(dir, ent.name);
|
|
31
|
+
const childRel = rel ? `${rel}/${ent.name}` : ent.name;
|
|
32
|
+
if (ent.isDirectory()) {
|
|
33
|
+
yield* walk(childAbs, childRel);
|
|
34
|
+
} else if (ent.isFile()) {
|
|
35
|
+
yield { abs: childAbs, rel: childRel };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function addOnce(manifest: StaticManifest, key: string, entry: StaticEntry) {
|
|
41
|
+
if (key.startsWith(RESERVED_PREFIX)) return;
|
|
42
|
+
if (manifest.has(key)) return;
|
|
43
|
+
manifest.set(key, entry);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildStaticManifest(outDir: string): StaticManifest {
|
|
47
|
+
const manifest: StaticManifest = new Map();
|
|
48
|
+
const outAbs = resolvePath(outDir);
|
|
49
|
+
|
|
50
|
+
const clientRoot = join(outAbs, "client");
|
|
51
|
+
if (existsSync(clientRoot)) {
|
|
52
|
+
for (const { abs, rel } of walk(clientRoot)) {
|
|
53
|
+
const cacheControl = HASHED_BASENAME.test(basename(rel))
|
|
54
|
+
? IMMUTABLE_CACHE
|
|
55
|
+
: DEFAULT_CACHE;
|
|
56
|
+
addOnce(manifest, `/dist/client/${rel}`, { absPath: abs, cacheControl });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const publicRoot = resolvePath("./public");
|
|
61
|
+
if (existsSync(publicRoot)) {
|
|
62
|
+
for (const { abs, rel } of walk(publicRoot)) {
|
|
63
|
+
addOnce(manifest, `/${rel}`, { absPath: abs });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (existsSync(outAbs)) {
|
|
68
|
+
let rootEntries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
69
|
+
try {
|
|
70
|
+
rootEntries = readdirSync(outAbs, {
|
|
71
|
+
withFileTypes: true,
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
}) as unknown as Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
74
|
+
} catch {
|
|
75
|
+
rootEntries = [];
|
|
76
|
+
}
|
|
77
|
+
for (const ent of rootEntries) {
|
|
78
|
+
if (ent.isDirectory()) {
|
|
79
|
+
if (OUT_DIR_SKIP_DIRS.has(ent.name)) continue;
|
|
80
|
+
const sub = join(outAbs, ent.name);
|
|
81
|
+
for (const { abs, rel } of walk(sub, ent.name)) {
|
|
82
|
+
addOnce(manifest, `/${rel}`, { absPath: abs });
|
|
83
|
+
}
|
|
84
|
+
} else if (ent.isFile()) {
|
|
85
|
+
if (OUT_DIR_SKIP_FILES.has(ent.name)) continue;
|
|
86
|
+
addOnce(manifest, `/${ent.name}`, { absPath: join(outAbs, ent.name) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return manifest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
|
|
95
|
+
const key = urlPath.split("?")[0];
|
|
96
|
+
return manifest.get(key) ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Re-export for tests that want to confirm a file-on-disk exists at the entry.
|
|
100
|
+
export function entryFileExists(entry: StaticEntry): boolean {
|
|
101
|
+
try {
|
|
102
|
+
return statSync(entry.absPath).isFile();
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"dev": "bosia dev",
|
|
7
7
|
"build": "bosia build",
|
|
8
8
|
"start": "bosia start",
|
|
9
|
-
"check": "
|
|
9
|
+
"check": "bosia sync && svelte-check --tsconfig ./tsconfig.json && tsc --noEmit",
|
|
10
10
|
"format": "prettier --write .",
|
|
11
11
|
"format:check": "prettier --check ."
|
|
12
12
|
},
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"@types/bun": "latest",
|
|
20
20
|
"prettier": "^3.3.0",
|
|
21
21
|
"prettier-plugin-svelte": "^3.2.0",
|
|
22
|
+
"svelte-check": "^4.4.8",
|
|
22
23
|
"typescript": "^5"
|
|
23
24
|
}
|
|
24
25
|
}
|