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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.6.8",
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
@@ -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 { router } from "./router.svelte.ts";
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) window.scrollTo(0, 0);
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
- if (router.isPush && !router.suppressScroll) window.scrollTo(0, 0);
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
  });
@@ -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 };`);
@@ -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
- // dist/client: serve with cache headers based on whether filename is hashed
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
- const filename = path.split("/").pop() ?? "";
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
@@ -1,3 +1,5 @@
1
+ /// <reference path="../ambient.d.ts" />
2
+
1
3
  // ─── Bosia Public API ─────────────────────────────────────
2
4
  // Usage in user apps:
3
5
  // import { cn, sequence } from "bosia"
@@ -6,7 +6,7 @@
6
6
  "dev": "bosia dev",
7
7
  "build": "bosia build",
8
8
  "start": "bosia start",
9
- "check": "tsc --noEmit && prettier --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
  }
@@ -17,6 +17,6 @@
17
17
  "$lib/*": ["./src/lib/*"]
18
18
  }
19
19
  },
20
- "include": ["src/**/*"],
20
+ "include": ["src/**/*", ".bosia/types/**/*.d.ts"],
21
21
  "exclude": ["node_modules", "dist"]
22
22
  }