bosia 0.5.8 → 0.5.9

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.8",
3
+ "version": "0.5.9",
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": [
@@ -0,0 +1,106 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ // ─── Types ────────────────────────────────────────────────
5
+
6
+ export type AppHtmlSegments = {
7
+ headOpen: string;
8
+ headClose: string;
9
+ tail: string;
10
+ hasCustomFavicon: boolean;
11
+ };
12
+
13
+ // ─── Cached Singleton ─────────────────────────────────────
14
+
15
+ let cachedSegments: AppHtmlSegments | undefined;
16
+
17
+ // ─── Parse & Validate ─────────────────────────────────────
18
+
19
+ export function loadAppHtmlTemplate(cwd: string = process.cwd()): AppHtmlSegments {
20
+ const templatePath = join(cwd, "src", "app.html");
21
+
22
+ if (!existsSync(templatePath)) {
23
+ throw new Error(
24
+ `src/app.html is required but not found. Create src/app.html with %bosia.head% and %bosia.body% placeholders.`,
25
+ );
26
+ }
27
+
28
+ let template = readFileSync(templatePath, "utf-8");
29
+
30
+ // Replace static placeholders at parse time
31
+ template = replaceStaticPlaceholders(template);
32
+
33
+ // Validate required placeholders
34
+ const missingPlaceholders: string[] = [];
35
+ if (!template.includes("%bosia.head%")) missingPlaceholders.push("%bosia.head%");
36
+ if (!template.includes("%bosia.body%")) missingPlaceholders.push("%bosia.body%");
37
+
38
+ if (missingPlaceholders.length > 0) {
39
+ throw new Error(
40
+ `src/app.html is missing required placeholder(s): ${missingPlaceholders.join(", ")}. ` +
41
+ `Both %bosia.head% and %bosia.body% must be present.`,
42
+ );
43
+ }
44
+
45
+ // Split into segments
46
+ const [headOpen, rest1] = template.split("%bosia.head%", 2);
47
+ const [headClose, tail] = rest1.split("%bosia.body%", 2);
48
+
49
+ // Check for custom favicon
50
+ const hasCustomFavicon = headOpen.includes('rel="icon"');
51
+
52
+ return {
53
+ headOpen,
54
+ headClose,
55
+ tail,
56
+ hasCustomFavicon,
57
+ };
58
+ }
59
+
60
+ function replaceStaticPlaceholders(template: string): string {
61
+ // Replace %bosia.assets% with BOSIA_ASSETS_BASE env var
62
+ const assetsBase = process.env.BOSIA_ASSETS_BASE || "";
63
+ template = template.replaceAll("%bosia.assets%", assetsBase);
64
+
65
+ // Replace %bosia.env.PUBLIC_FOO% with process.env.PUBLIC_FOO
66
+ template = template.replace(/%bosia\.env\.(PUBLIC_[A-Z0-9_]+)%/g, (_match, key: string) => {
67
+ return process.env[key] ?? "";
68
+ });
69
+
70
+ return template;
71
+ }
72
+
73
+ // ─── Cached Getter ────────────────────────────────────────
74
+
75
+ export function getAppHtmlSegments(cwd: string = process.cwd()): AppHtmlSegments {
76
+ if (cachedSegments !== undefined) {
77
+ return cachedSegments;
78
+ }
79
+ cachedSegments = loadAppHtmlTemplate(cwd);
80
+ return cachedSegments;
81
+ }
82
+
83
+ // ─── Cache Invalidation ───────────────────────────────────
84
+
85
+ export function invalidateAppHtmlCache(): void {
86
+ cachedSegments = undefined;
87
+ }
88
+
89
+ // ─── Runtime Interpolation ────────────────────────────────
90
+
91
+ export function interpolateSegment(
92
+ segment: string,
93
+ vars: { nonce?: string; lang?: string },
94
+ ): string {
95
+ let result = segment;
96
+
97
+ if (vars.lang) {
98
+ result = result.replaceAll("%bosia.lang%", vars.lang);
99
+ }
100
+
101
+ if (vars.nonce) {
102
+ result = result.replaceAll("%bosia.nonce%", vars.nonce);
103
+ }
104
+
105
+ return result;
106
+ }
package/src/core/build.ts CHANGED
@@ -12,6 +12,7 @@ import { generateEnvModules } from "./envCodegen.ts";
12
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
+ import { loadAppHtmlTemplate } from "./appHtml.ts";
15
16
 
16
17
  // Resolved from this file's location inside the bosia package
17
18
  const CORE_DIR = import.meta.dir;
@@ -79,6 +80,20 @@ if (manifest.apis.length > 0) {
79
80
  }
80
81
  }
81
82
 
83
+ // 1b. Load & validate src/app.html template (required)
84
+ let appHtml: any;
85
+ try {
86
+ appHtml = loadAppHtmlTemplate(process.cwd());
87
+ console.log(
88
+ "📄 Loaded src/app.html (favicon override: " +
89
+ (appHtml.hasCustomFavicon ? "yes" : "no") +
90
+ ")",
91
+ );
92
+ } catch (err) {
93
+ console.error(`❌ src/app.html validation failed:\n${(err as Error).message}`);
94
+ process.exit(1);
95
+ }
96
+
82
97
  for (const p of userPlugins) {
83
98
  if (p.build?.postScan) {
84
99
  await p.build.postScan(manifest, buildCtx);
package/src/core/html.ts CHANGED
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "fs";
2
2
  import { getDeclaredEnvKeys } from "./env.ts";
3
3
  import { nonceAttr } from "./csp.ts";
4
4
  import { OUT_DIR } from "./paths.ts";
5
+ import type { AppHtmlSegments } from "./appHtml.ts";
6
+ import { interpolateSegment } from "./appHtml.ts";
5
7
 
6
8
  // ─── Dist Manifest ───────────────────────────────────────
7
9
  // Maps hashed filenames → script/link tags.
@@ -98,6 +100,7 @@ export function buildHtml(
98
100
  pageDeps: any = null,
99
101
  layoutDeps: any[] | null = null,
100
102
  bodyEndExtras?: string[],
103
+ segments?: AppHtmlSegments,
101
104
  ): string {
102
105
  const cssLinks = (distManifest.css ?? [])
103
106
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
@@ -138,6 +141,31 @@ export function buildHtml(
138
141
 
139
142
  const bodyEnd = bodyEndExtras?.length ? "\n " + bodyEndExtras.join("\n ") : "";
140
143
 
144
+ if (segments) {
145
+ const safeKey = safeLang(lang);
146
+ const headOpenInterpolated = interpolateSegment(segments.headOpen, {
147
+ lang: safeKey,
148
+ nonce,
149
+ });
150
+ const headCloseInterpolated = interpolateSegment(segments.headClose, { nonce });
151
+ const tailInterpolated = interpolateSegment(segments.tail, { nonce });
152
+ const faviconLine = segments.hasCustomFavicon
153
+ ? ""
154
+ : ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n`;
155
+
156
+ return (
157
+ headOpenInterpolated +
158
+ `\n ${faviconLine}${cssLinks}\n` +
159
+ ` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
160
+ ` <script${n}>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` +
161
+ ` ${fallbackTitle}${head}` +
162
+ headCloseInterpolated +
163
+ `\n${SPINNER}` +
164
+ `\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
165
+ tailInterpolated
166
+ );
167
+ }
168
+
141
169
  return `<!DOCTYPE html>
142
170
  <html lang="${safeLang(lang)}">
143
171
  <head>
@@ -161,12 +189,31 @@ export function buildHtml(
161
189
  import type { Metadata } from "./hooks.ts";
162
190
 
163
191
  /** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
164
- export function buildHtmlShellOpen(lang?: string, nonce?: string): string {
192
+ export function buildHtmlShellOpen(
193
+ lang?: string,
194
+ nonce?: string,
195
+ segments?: AppHtmlSegments,
196
+ ): string {
165
197
  const key = safeLang(lang);
166
198
  const n = nonceAttr(nonce);
167
199
  const cssLinks = (distManifest.css ?? [])
168
200
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
169
201
  .join("\n ");
202
+
203
+ if (segments) {
204
+ const headOpenInterpolated = interpolateSegment(segments.headOpen, { lang: key, nonce });
205
+ const faviconLine = segments.hasCustomFavicon
206
+ ? ""
207
+ : ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n`;
208
+ return (
209
+ headOpenInterpolated +
210
+ `\n ${faviconLine}${cssLinks}\n` +
211
+ ` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
212
+ ` <script${n}>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` +
213
+ ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
214
+ );
215
+ }
216
+
170
217
  return (
171
218
  `<!DOCTYPE html>\n<html lang="${key}">\n<head>\n` +
172
219
  ` <meta charset="UTF-8">\n` +
@@ -188,7 +235,11 @@ const SPINNER =
188
235
  `@keyframes __bs__{to{transform:rotate(360deg)}}</style><i></i></div>`;
189
236
 
190
237
  /** Chunk 2: metadata tags + close </head> + open <body> + spinner */
191
- export function buildMetadataChunk(metadata: Metadata | null, headExtras?: string[]): string {
238
+ export function buildMetadataChunk(
239
+ metadata: Metadata | null,
240
+ headExtras?: string[],
241
+ segments?: AppHtmlSegments,
242
+ ): string {
192
243
  let out = "\n";
193
244
  if (metadata) {
194
245
  if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
@@ -218,7 +269,14 @@ export function buildMetadataChunk(metadata: Metadata | null, headExtras?: strin
218
269
  if (fragment) out += ` ${fragment}\n`;
219
270
  }
220
271
  }
221
- out += `</head>\n<body>\n${SPINNER}`;
272
+
273
+ if (segments) {
274
+ const headCloseInterpolated = interpolateSegment(segments.headClose, {});
275
+ out += headCloseInterpolated + `\n${SPINNER}`;
276
+ } else {
277
+ out += `</head>\n<body>\n${SPINNER}`;
278
+ }
279
+
222
280
  return out;
223
281
  }
224
282
 
@@ -246,6 +304,7 @@ export function buildHtmlTail(
246
304
  nonce?: string,
247
305
  pageDeps: any = null,
248
306
  layoutDeps: any[] | null = null,
307
+ segments?: AppHtmlSegments,
249
308
  ): string {
250
309
  const n = nonceAttr(nonce);
251
310
  let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
@@ -279,7 +338,14 @@ export function buildHtmlTail(
279
338
  if (fragment) out += `\n${fragment}`;
280
339
  }
281
340
  }
282
- out += `\n</body>\n</html>`;
341
+
342
+ if (segments) {
343
+ const tailInterpolated = interpolateSegment(segments.tail, { nonce });
344
+ out += `\n${tailInterpolated}`;
345
+ } else {
346
+ out += `\n</body>\n</html>`;
347
+ }
348
+
283
349
  return out;
284
350
  }
285
351
 
@@ -159,12 +159,14 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
159
159
  }
160
160
 
161
161
  let js = dev ? fixBindShadow(result.js.code) : result.js.code;
162
- // Skip empty CSS multiple +page.svelte routes with no <style> would
163
- // otherwise each emit an empty CSS chunk, all hashing to the same
164
- // content Bun fails with "Multiple files share the same output path".
165
- // Also strip dots from basename: Bun's `[name]-[hash].[ext]` chunk
166
- // naming treats the first `.` as the extension boundary, so
167
- // `+page.svelte-…` collapses to `[name]="+page"` for every route.
162
+ // Inject component <style> blocks via runtime JS, not CSS chunks.
163
+ // Bun's `splitting: true` names CSS chunks after the importing JS
164
+ // chunk's `[name]`, not the virtual module's uid so when several
165
+ // routes (e.g. multiple `+page.svelte`) transitively import the same
166
+ // styled component, each route emits its own CSS sidecar named
167
+ // `+page-<contentHash>.css`. Identical content identical hash
168
+ // "Multiple files share the same output path". Runtime injection
169
+ // avoids CSS chunking entirely.
168
170
  if (result.css?.code?.trim() && generate !== "server") {
169
171
  const safeBase = basename(args.path).replace(/\./g, "_");
170
172
  const uid = `${safeBase}-${fnv(args.path)}-style.css`;
@@ -184,7 +186,10 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
184
186
  build.onLoad({ filter: /.*/, namespace: VIRTUAL_NS }, (args) => {
185
187
  const css = virtualCss.get(args.path) ?? "";
186
188
  virtualCss.delete(args.path);
187
- return { contents: css, loader: "css" };
189
+ const contents = css
190
+ ? `(()=>{const s=document.createElement('style');s.textContent=${JSON.stringify(css)};document.head.appendChild(s);})();`
191
+ : "";
192
+ return { contents, loader: "js" };
188
193
  });
189
194
  },
190
195
  };
@@ -20,6 +20,8 @@ import type { Metadata } from "./hooks.ts";
20
20
  import { loadPlugins } from "./config.ts";
21
21
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
22
22
  import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
23
+ import { getAppHtmlSegments } from "./appHtml.ts";
24
+ import type { AppHtmlSegments } from "./appHtml.ts";
23
25
 
24
26
  // Plugins are loaded once per process at module init via top-level await elsewhere
25
27
  // (server.ts), but renderer is also reachable from build/prerender contexts where
@@ -99,6 +101,11 @@ if (INTERNAL_HOSTS.size > 0) {
99
101
  console.log(`🍪 Internal hosts (cookies forwarded): ${[...INTERNAL_HOSTS].join(", ")}`);
100
102
  }
101
103
 
104
+ // ─── App HTML Template ───────────────────────────────────
105
+ // Required; loaded once per process; cached singleton with invalidation for HMR
106
+
107
+ const appHtmlSegments: AppHtmlSegments = getAppHtmlSegments();
108
+
102
109
  // ─── Session-Aware Fetch ─────────────────────────────────
103
110
  // Passed to load() functions so they can call internal APIs with the
104
111
  // current user's cookies automatically forwarded. Cookie is attached
@@ -607,8 +614,8 @@ export async function renderSSRStream(
607
614
  );
608
615
  }
609
616
  const html =
610
- buildHtmlShellOpen(metadata?.lang, nonce) +
611
- buildMetadataChunk(metadata, headExtras) +
617
+ buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments) +
618
+ buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
612
619
  buildHtmlTail(
613
620
  "",
614
621
  "",
@@ -621,6 +628,7 @@ export async function renderSSRStream(
621
628
  nonce,
622
629
  data.pageDeps,
623
630
  data.layoutDeps,
631
+ appHtmlSegments,
624
632
  );
625
633
  return new Response(html, {
626
634
  headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -659,8 +667,8 @@ export async function renderSSRStream(
659
667
 
660
668
  // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
661
669
  const chunks: Uint8Array[] = [
662
- enc.encode(buildHtmlShellOpen(metadata?.lang, nonce)),
663
- enc.encode(buildMetadataChunk(metadata, headExtras)),
670
+ enc.encode(buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments)),
671
+ enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
664
672
  enc.encode(
665
673
  buildHtmlTail(
666
674
  body,
@@ -674,6 +682,7 @@ export async function renderSSRStream(
674
682
  nonce,
675
683
  data.pageDeps,
676
684
  data.layoutDeps,
685
+ appHtmlSegments,
677
686
  ),
678
687
  ),
679
688
  ];
@@ -781,6 +790,8 @@ export async function renderPageWithFormData(
781
790
  nonce,
782
791
  data.pageDeps,
783
792
  data.layoutDeps,
793
+ undefined,
794
+ appHtmlSegments,
784
795
  );
785
796
  return compress(html, "text/html; charset=utf-8", req, status);
786
797
  }
@@ -808,6 +819,8 @@ export async function renderPageWithFormData(
808
819
  nonce,
809
820
  data.pageDeps,
810
821
  data.layoutDeps,
822
+ undefined,
823
+ appHtmlSegments,
811
824
  );
812
825
  return compress(html, "text/html; charset=utf-8", req, status);
813
826
  }
@@ -887,6 +900,7 @@ export async function renderErrorPage(
887
900
  null,
888
901
  null,
889
902
  bodyEndExtras,
903
+ appHtmlSegments,
890
904
  );
891
905
  return compress(html, "text/html; charset=utf-8", req, status);
892
906
  } catch (err) {
@@ -924,6 +938,7 @@ export async function renderErrorPage(
924
938
  null,
925
939
  null,
926
940
  bodyEndExtras,
941
+ appHtmlSegments,
927
942
  );
928
943
  return compress(html, "text/html; charset=utf-8", req, status);
929
944
  } catch (err) {
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="%bosia.lang%">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ %bosia.head%
7
+ </head>
8
+ <body>
9
+ %bosia.body%
10
+ </body>
11
+ </html>
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="%bosia.lang%" data-theme="neutral">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ %bosia.head%
7
+ </head>
8
+ <body>
9
+ %bosia.body%
10
+ </body>
11
+ </html>
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="%bosia.lang%">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ %bosia.head%
7
+ </head>
8
+ <body>
9
+ %bosia.body%
10
+ </body>
11
+ </html>