bosia 0.5.4 → 0.5.5

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.5.4",
3
+ "version": "0.5.5",
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/start.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  import { spawn } from "bun";
2
2
  import { loadEnv } from "../core/env.ts";
3
- import { BOSIA_NODE_PATH } from "../core/paths.ts";
3
+ import { BOSIA_NODE_PATH, OUT_DIR } from "../core/paths.ts";
4
4
 
5
5
  export async function runStart() {
6
6
  loadEnv("production");
7
7
 
8
8
  let serverEntry = "index.js";
9
9
  try {
10
- const manifest = await Bun.file("./dist/manifest.json").json();
10
+ const manifest = await Bun.file(`${OUT_DIR}/manifest.json`).json();
11
11
  serverEntry = manifest.serverEntry ?? "index.js";
12
12
  } catch {}
13
13
 
14
- const proc = spawn(["bun", "run", `dist/server/${serverEntry}`], {
14
+ const proc = spawn(["bun", "run", `${OUT_DIR}/server/${serverEntry}`], {
15
15
  stdout: "inherit",
16
16
  stderr: "inherit",
17
17
  cwd: process.cwd(),
package/src/core/build.ts CHANGED
@@ -9,7 +9,7 @@ import { makeBosiaSvelteCompiler } from "./svelteCompiler.ts";
9
9
  import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
10
10
  import { loadEnv, classifyEnvVars } from "./env.ts";
11
11
  import { generateEnvModules } from "./envCodegen.ts";
12
- import { BOSIA_NODE_PATH, resolveBosiaBin } from "./paths.ts";
12
+ import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin } from "./paths.ts";
13
13
  import { loadPlugins } from "./config.ts";
14
14
  import type { BuildContext } from "./types/plugin.ts";
15
15
 
@@ -47,7 +47,7 @@ const classifiedEnv = classifyEnvVars(envVars);
47
47
 
48
48
  // 0b. Clean all generated output first
49
49
  try {
50
- rmSync("./dist", { recursive: true, force: true });
50
+ rmSync(OUT_DIR, { recursive: true, force: true });
51
51
  } catch {}
52
52
  try {
53
53
  rmSync("./.bosia", { recursive: true, force: true });
@@ -125,7 +125,7 @@ for (const [key, value] of Object.entries(classifiedEnv.privateStatic)) {
125
125
  console.log("\n📦 Building Tailwind + client + server...");
126
126
  const clientPromise = Bun.build({
127
127
  entrypoints: [join(CORE_DIR, "client", "hydrate.ts")],
128
- outdir: "./dist/client",
128
+ outdir: `${OUT_DIR}/client`,
129
129
  target: "browser",
130
130
  splitting: true,
131
131
  naming: { chunk: "[name]-[hash].[ext]" },
@@ -139,7 +139,7 @@ const clientPromise = Bun.build({
139
139
 
140
140
  const serverPromise = Bun.build({
141
141
  entrypoints: [join(CORE_DIR, "server.ts")],
142
- outdir: "./dist/server",
142
+ outdir: `${OUT_DIR}/server`,
143
143
  target: "bun",
144
144
  splitting: true,
145
145
  naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
@@ -177,7 +177,7 @@ if (!serverResult.success) {
177
177
  const jsFiles: string[] = [];
178
178
  const cssFiles: string[] = [];
179
179
  for (const output of clientResult.outputs) {
180
- const rel = relative("./dist/client", output.path);
180
+ const rel = relative(`${OUT_DIR}/client`, output.path);
181
181
  if (output.path.endsWith(".js")) jsFiles.push(rel);
182
182
  if (output.path.endsWith(".css")) cssFiles.push(rel);
183
183
  }
@@ -190,7 +190,7 @@ const serverEntry =
190
190
  .pop() ?? "index.js";
191
191
 
192
192
  // 8. Write dist/manifest.json
193
- mkdirSync("./dist", { recursive: true });
193
+ mkdirSync(OUT_DIR, { recursive: true });
194
194
  const distManifest = {
195
195
  js: jsFiles,
196
196
  css: cssFiles,
@@ -200,12 +200,12 @@ const distManifest = {
200
200
  "hydrate.js",
201
201
  serverEntry,
202
202
  };
203
- writeFileSync("./dist/manifest.json", JSON.stringify(distManifest, null, 2));
203
+ writeFileSync(`${OUT_DIR}/manifest.json`, JSON.stringify(distManifest, null, 2));
204
204
  console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
205
- console.log(`✅ Server entry: dist/server/${serverEntry}`);
205
+ console.log(`✅ Server entry: ${OUT_DIR}/server/${serverEntry}`);
206
206
 
207
207
  // 8b. Persist route manifest for runtime plugins (backend.after consumers like OpenAPI).
208
- writeFileSync("./dist/route-manifest.json", JSON.stringify(manifest, null, 2));
208
+ writeFileSync(`${OUT_DIR}/route-manifest.json`, JSON.stringify(manifest, null, 2));
209
209
 
210
210
  // 9. Prerender static routes
211
211
  await prerenderStaticRoutes(manifest);
@@ -68,11 +68,14 @@ export function enhance(form: HTMLFormElement, submit?: SubmitFunction) {
68
68
  const submitter = (event.submitter as HTMLElement | null) ?? null;
69
69
  const formData = new FormData(form, submitter as HTMLElement | undefined);
70
70
 
71
- // Resolve action URL — preserve `?/actionName` if the submitter or form sets it
72
- const actionAttr =
73
- (submitter as HTMLButtonElement | HTMLInputElement | null)?.formAction ||
74
- form.action ||
75
- window.location.href;
71
+ // Resolve action URL — preserve `?/actionName` if the submitter or form sets it.
72
+ // `submitter.formAction` reflects the document URL when no `formaction` attribute
73
+ // is set (HTML spec), which would silently drop the form's `action="?/foo"`. So
74
+ // only honor the submitter override when it actually has the attribute.
75
+ const submitterEl = submitter as HTMLButtonElement | HTMLInputElement | null;
76
+ const submitterAction =
77
+ submitterEl && submitterEl.hasAttribute("formaction") ? submitterEl.formAction : "";
78
+ const actionAttr = submitterAction || form.action || window.location.href;
76
79
  const action = new URL(actionAttr, window.location.href);
77
80
 
78
81
  let cancelled = false;
package/src/core/dev.ts CHANGED
@@ -2,6 +2,13 @@ import { spawn, type Subprocess } from "bun";
2
2
  import { watch } from "fs";
3
3
  import { join } from "path";
4
4
  import { loadEnv, resetDeclaredKeys } from "./env.ts";
5
+ import { BOSIA_NODE_PATH } from "./paths.ts";
6
+
7
+ // Dev always writes to .bosia/dev so a parallel `bun run build` (writing to ./dist)
8
+ // can't clobber the live preview. Hardcoded — BOSIA_OUT_DIR is a build-mode knob,
9
+ // not a dev knob; we pass it to spawned children to redirect build.ts/server output,
10
+ // but dev.ts itself never reads it.
11
+ const DEV_OUT_DIR = ".bosia/dev";
5
12
 
6
13
  // Snapshot pure shell env BEFORE any loadEnv call pollutes process.env.
7
14
  // On `.env*` change we restore from this snapshot, then re-run loadEnv,
@@ -49,8 +56,6 @@ function broadcastReload() {
49
56
 
50
57
  // ─── Build ────────────────────────────────────────────────
51
58
 
52
- import { BOSIA_NODE_PATH } from "./paths.ts";
53
-
54
59
  const BUILD_SCRIPT = join(import.meta.dir, "build.ts");
55
60
 
56
61
  async function runBuild(): Promise<boolean> {
@@ -59,6 +64,7 @@ async function runBuild(): Promise<boolean> {
59
64
  stdout: "inherit",
60
65
  stderr: "inherit",
61
66
  cwd: process.cwd(),
67
+ env: { ...process.env, BOSIA_OUT_DIR: DEV_OUT_DIR },
62
68
  });
63
69
  return (await proc.exited) === 0;
64
70
  }
@@ -79,11 +85,11 @@ async function startAppServer() {
79
85
  // Read the server entry filename from the manifest written by build.ts
80
86
  let serverEntry = "index.js";
81
87
  try {
82
- const manifest = await Bun.file("./dist/manifest.json").json();
88
+ const manifest = await Bun.file(`${DEV_OUT_DIR}/manifest.json`).json();
83
89
  serverEntry = manifest.serverEntry ?? "index.js";
84
90
  } catch {}
85
91
 
86
- appProcess = spawn(["bun", "run", `dist/server/${serverEntry}`], {
92
+ appProcess = spawn(["bun", "run", `${DEV_OUT_DIR}/server/${serverEntry}`], {
87
93
  stdout: "inherit",
88
94
  stderr: "inherit",
89
95
  cwd: process.cwd(),
@@ -94,6 +100,8 @@ async function startAppServer() {
94
100
  PORT: String(APP_PORT),
95
101
  // Allow externalized deps (elysia, etc.) to resolve from bosia's node_modules
96
102
  NODE_PATH: BOSIA_NODE_PATH,
103
+ // Point the server child at dev's output dir so its OUT_DIR reads match what build wrote.
104
+ BOSIA_OUT_DIR: DEV_OUT_DIR,
97
105
  // Dev proxy injects X-Forwarded-Host/Proto reflecting the public DEV_PORT, so CSRF
98
106
  // origin checks must honour them. Safe in dev because the proxy controls these
99
107
  // headers — no untrusted client can spoof them.
package/src/core/html.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { getDeclaredEnvKeys } from "./env.ts";
3
3
  import { nonceAttr } from "./csp.ts";
4
+ import { OUT_DIR } from "./paths.ts";
4
5
 
5
6
  // ─── Dist Manifest ───────────────────────────────────────
6
7
  // Maps hashed filenames → script/link tags.
7
8
  // Cached at startup; server restarts on rebuild in dev anyway.
8
9
 
9
10
  export const distManifest: { js: string[]; css: string[]; entry: string } = (() => {
10
- const p = "./dist/manifest.json";
11
+ const p = `${OUT_DIR}/manifest.json`;
11
12
  return existsSync(p)
12
13
  ? JSON.parse(readFileSync(p, "utf-8"))
13
14
  : { js: [], css: [], entry: "hydrate.js" };
package/src/core/paths.ts CHANGED
@@ -18,6 +18,11 @@ const HOISTED_NM = isInstalledAsDep ? parentDir : null;
18
18
  /** NODE_PATH value covering both nested and hoisted dependency locations */
19
19
  export const BOSIA_NODE_PATH = HOISTED_NM ? [NESTED_NM, HOISTED_NM].join(":") : NESTED_NM;
20
20
 
21
+ // On-disk output directory. URL namespace (/dist/client/...) stays stable;
22
+ // only the on-disk location moves so dev (.bosia/dev) and a parallel
23
+ // `bun run build` (./dist) don't clobber each other.
24
+ export const OUT_DIR = process.env.BOSIA_OUT_DIR ?? "./dist";
25
+
21
26
  /** Find a binary from bosia's dependencies (handles hoisting) */
22
27
  export function resolveBosiaBin(name: string): string {
23
28
  const nested = join(NESTED_NM, ".bin", name);
@@ -3,7 +3,7 @@ import { createServer } from "net";
3
3
  import { join } from "path";
4
4
  import type { RouteManifest, TrailingSlash } from "./types.ts";
5
5
 
6
- import { BOSIA_NODE_PATH } from "./paths.ts";
6
+ import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
7
7
 
8
8
  /** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
9
9
  export function getEphemeralPort(): Promise<number> {
@@ -78,9 +78,9 @@ export function canonicalRouteFor(routePath: string, ts: TrailingSlash): string
78
78
  * mode so static hosts serve the right file on direct URL hits.
79
79
  */
80
80
  export function prerenderOutPath(routePath: string, ts: TrailingSlash): string {
81
- if (routePath === "/") return "./dist/prerendered/index.html";
82
- if (ts === "never") return `./dist/prerendered${routePath.replace(/\/$/, "")}.html`;
83
- return `./dist/prerendered${routePath.replace(/\/$/, "")}/index.html`;
81
+ if (routePath === "/") return `${OUT_DIR}/prerendered/index.html`;
82
+ if (ts === "never") return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.html`;
83
+ return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}/index.html`;
84
84
  }
85
85
 
86
86
  /** Data-payload filename for a prerendered route — matches client `dataUrl()`. */
@@ -94,7 +94,7 @@ export function prerenderDataPath(routePath: string): string {
94
94
  * `/api/foo.json` regardless of the request URL's slash).
95
95
  */
96
96
  export function prerenderApiOutPath(routePath: string): string {
97
- return `./dist/prerendered${routePath.replace(/\/$/, "")}.json`;
97
+ return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
98
98
  }
99
99
 
100
100
  async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
@@ -179,7 +179,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
179
179
  console.log(`\n🖨️ Prerendering ${targets.length} route(s)...`);
180
180
 
181
181
  const port = await getEphemeralPort();
182
- const child = Bun.spawn(["bun", "run", "./dist/server/index.js"], {
182
+ const child = Bun.spawn(["bun", "run", `${OUT_DIR}/server/index.js`], {
183
183
  env: {
184
184
  ...process.env,
185
185
  NODE_ENV: "production",
@@ -219,7 +219,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
219
219
  return;
220
220
  }
221
221
 
222
- mkdirSync("./dist/prerendered", { recursive: true });
222
+ mkdirSync(`${OUT_DIR}/prerendered`, { recursive: true });
223
223
 
224
224
  for (const { path: routePath, kind, trailingSlash: ts } of targets) {
225
225
  try {
@@ -260,7 +260,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
260
260
  });
261
261
  if (dataRes.ok) {
262
262
  const dataJson = await dataRes.text();
263
- const dataOutPath = `./dist/prerendered/__bosia/data${dataPath}`;
263
+ const dataOutPath = `${OUT_DIR}/prerendered/__bosia/data${dataPath}`;
264
264
  mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
265
265
  recursive: true,
266
266
  });
@@ -295,24 +295,24 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
295
295
  // ─── Static Site Output ──────────────────────────────────
296
296
 
297
297
  export function generateStaticSite(): void {
298
- if (!existsSync("./dist/prerendered")) {
298
+ if (!existsSync(`${OUT_DIR}/prerendered`)) {
299
299
  console.log("\n⏭️ No prerendered pages — skipping static site output");
300
300
  return;
301
301
  }
302
302
 
303
303
  console.log("\n📦 Generating static site...");
304
- mkdirSync("./dist/static", { recursive: true });
304
+ mkdirSync(`${OUT_DIR}/static`, { recursive: true });
305
305
 
306
306
  // 1. HTML files from prerendering
307
- cpSync("./dist/prerendered", "./dist/static", { recursive: true });
307
+ cpSync(`${OUT_DIR}/prerendered`, `${OUT_DIR}/static`, { recursive: true });
308
308
 
309
309
  // 2. Client JS/CSS — preserves /dist/client/... absolute paths used in HTML
310
- cpSync("./dist/client", "./dist/static/dist/client", { recursive: true });
310
+ cpSync(`${OUT_DIR}/client`, `${OUT_DIR}/static/dist/client`, { recursive: true });
311
311
 
312
312
  // 3. Public assets (bosia-tw.css, favicon, etc.) — preserves /bosia-tw.css path
313
313
  if (existsSync("./public")) {
314
- cpSync("./public", "./dist/static", { recursive: true });
314
+ cpSync("./public", `${OUT_DIR}/static`, { recursive: true });
315
315
  }
316
316
 
317
- console.log("✅ Static site generated: dist/static/");
317
+ console.log(`✅ Static site generated: ${OUT_DIR}/static/`);
318
318
  }
@@ -22,6 +22,7 @@ import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
22
22
  import type { CorsConfig } from "./cors.ts";
23
23
  import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
24
24
  import { isDev, compress, isStaticPath } from "./html.ts";
25
+ import { OUT_DIR } from "./paths.ts";
25
26
  import { dedup, dedupKey } from "./dedup.ts";
26
27
  import {
27
28
  loadRouteData,
@@ -288,7 +289,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
288
289
  // dist/client: serve with cache headers based on whether filename is hashed
289
290
  if (path.startsWith("/dist/client/")) {
290
291
  const resolved = safePath(
291
- "./dist/client",
292
+ `${OUT_DIR}/client`,
292
293
  path.split("?")[0].slice("/dist/client".length),
293
294
  );
294
295
  if (resolved) {
@@ -308,12 +309,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
308
309
  const pub = Bun.file(pubPath);
309
310
  if (await pub.exists()) return new Response(pub);
310
311
  }
311
- const distPath = safePath("./dist", path);
312
+ const distPath = safePath(OUT_DIR, path);
312
313
  if (distPath) {
313
314
  const dist = Bun.file(distPath);
314
315
  if (await dist.exists()) return new Response(dist);
315
316
  }
316
- const staticPath = safePath("./dist/static", path);
317
+ const staticPath = safePath(`${OUT_DIR}/static`, path);
317
318
  if (staticPath) {
318
319
  const staticFile = Bun.file(staticPath);
319
320
  if (await staticFile.exists()) return new Response(staticFile);
@@ -326,7 +327,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
326
327
  const prerenderCandidates =
327
328
  path === "/" ? ["index.html"] : [`${path}/index.html`, `${path.replace(/\/$/, "")}.html`];
328
329
  for (const candidate of prerenderCandidates) {
329
- const prerenderPath = safePath("./dist/prerendered", candidate);
330
+ const prerenderPath = safePath(`${OUT_DIR}/prerendered`, candidate);
330
331
  if (!prerenderPath) continue;
331
332
  const prerenderFile = Bun.file(prerenderPath);
332
333
  if (await prerenderFile.exists()) {
@@ -724,7 +725,7 @@ if (plugins.length > 0) {
724
725
 
725
726
  // Read the build-time route manifest so plugins.backend.after can introspect routes.
726
727
  function loadBuiltManifest(): RouteManifest {
727
- const path = "./dist/route-manifest.json";
728
+ const path = `${OUT_DIR}/route-manifest.json`;
728
729
  if (existsSync(path)) {
729
730
  try {
730
731
  return JSON.parse(readFileSync(path, "utf-8"));
@@ -2,9 +2,7 @@
2
2
  <div class="flex flex-col items-center gap-3 text-center">
3
3
  <img src="/favicon.svg" alt="" class="size-16" />
4
4
  <h1 class="text-4xl font-bold tracking-tight">Welcome to Bosia</h1>
5
- <p class="text-muted-foreground text-lg">
6
- A Bosia project — SSR + Svelte 5 + Bun + ElysiaJS
7
- </p>
5
+ <p class="text-muted-foreground text-lg">A Bosia project — SSR + Svelte 5 + Bun + ElysiaJS</p>
8
6
  </div>
9
7
 
10
8
  <div class="mt-4 flex gap-3">
@@ -27,8 +25,7 @@
27
25
  </div>
28
26
 
29
27
  <p class="text-muted-foreground mt-6 text-sm">
30
- Edit <code class="bg-muted rounded px-1 py-0.5 font-mono text-xs"
31
- >src/routes/+page.svelte</code
32
- > to get started
28
+ Edit <code class="bg-muted rounded px-1 py-0.5 font-mono text-xs">src/routes/+page.svelte</code> to
29
+ get started
33
30
  </p>
34
31
  </main>
@@ -10,25 +10,18 @@
10
10
  <a href="/" class="font-bold tracking-tight flex items-center gap-2"
11
11
  ><img src="/favicon.svg" alt="" class="size-5" /> {data.appName}</a
12
12
  >
13
- <a
14
- href="/"
15
- class="text-sm text-muted-foreground hover:text-foreground transition-colors"
13
+ <a href="/" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
16
14
  >Home</a
17
15
  >
18
- <a
19
- href="/about"
20
- class="text-sm text-muted-foreground hover:text-foreground transition-colors"
16
+ <a href="/about" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
21
17
  >About</a
22
18
  >
23
- <a
24
- href="/blog"
25
- class="text-sm text-muted-foreground hover:text-foreground transition-colors"
19
+ <a href="/blog" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
26
20
  >Blog</a
27
21
  >
28
22
  <a
29
23
  href="/all/foo/bar"
30
- class="text-sm text-muted-foreground hover:text-foreground transition-colors"
31
- >Catch-all</a
24
+ class="text-sm text-muted-foreground hover:text-foreground transition-colors">Catch-all</a
32
25
  >
33
26
  <a
34
27
  href="/api/hello"
@@ -11,8 +11,7 @@
11
11
  slug: "route-groups",
12
12
  title: "Route Groups Explained",
13
13
  date: "2026-03-04",
14
- excerpt:
15
- "How (public), (auth), (admin) groups work — invisible in URLs, share layouts.",
14
+ excerpt: "How (public), (auth), (admin) groups work — invisible in URLs, share layouts.",
16
15
  tags: ["routing", "layouts"],
17
16
  },
18
17
  {
@@ -50,8 +49,7 @@
50
49
  </div>
51
50
  <div class="flex gap-1 shrink-0">
52
51
  {#each post.tags as tag}
53
- <span
54
- class="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
52
+ <span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
55
53
  >{tag}</span
56
54
  >
57
55
  {/each}
@@ -13,16 +13,14 @@
13
13
 
14
14
  {#if post}
15
15
  <article class="space-y-6 max-w-2xl">
16
- <a
17
- href="/blog"
18
- class="text-sm text-muted-foreground hover:text-foreground transition-colors">← Blog</a
16
+ <a href="/blog" class="text-sm text-muted-foreground hover:text-foreground transition-colors"
17
+ >← Blog</a
19
18
  >
20
19
 
21
20
  <div class="space-y-2">
22
21
  <div class="flex flex-wrap gap-1">
23
22
  {#each post.tags as tag}
24
- <span
25
- class="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
23
+ <span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
26
24
  >{tag}</span
27
25
  >
28
26
  {/each}
@@ -49,7 +49,5 @@
49
49
  </div>
50
50
  </div>
51
51
 
52
- <footer class="border-t py-4 text-center text-sm text-muted-foreground">
53
- Powered by Bosia
54
- </footer>
52
+ <footer class="border-t py-4 text-center text-sm text-muted-foreground">Powered by Bosia</footer>
55
53
  </div>