bosia 0.9.5 → 0.9.6

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.9.5",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
package/src/core/build.ts CHANGED
@@ -7,6 +7,7 @@ import { generateRoutesFile } from "./routeFile.ts";
7
7
  import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
8
8
  import { makeBosiaPlugin } from "./plugin.ts";
9
9
  import { makeBosiaSvelteCompiler, svelteMapCache } from "./svelteCompiler.ts";
10
+ import { finalizeComponentCss } from "./componentCss.ts";
10
11
  import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
11
12
  import { loadEnv, classifyEnvVars } from "./env.ts";
12
13
  import { generateEnvModules } from "./envCodegen.ts";
@@ -258,6 +259,17 @@ for (const output of clientResult.outputs) {
258
259
  }
259
260
  }
260
261
 
262
+ // Scoped component `<style>` blocks, harvested during the client compile and
263
+ // written as one stylesheet the head can link. Before this they rode inside the
264
+ // JS bundle, so every SSR'd page painted unstyled until hydration. Must land
265
+ // before the manifest write below: prerenderStaticRoutes() boots the built
266
+ // server, which reads manifest.json once at startup.
267
+ const componentCssFile = finalizeComponentCss(`${OUT_DIR}/client`);
268
+ if (componentCssFile) {
269
+ cssFiles.push(componentCssFile);
270
+ console.log(`✅ Component CSS built: ${OUT_DIR}/client/${componentCssFile}`);
271
+ }
272
+
261
273
  // Entry is always "index.js" due to naming: { entry: "index.[ext]" }
262
274
  const serverEntry =
263
275
  serverResult.outputs
@@ -0,0 +1,54 @@
1
+ import { writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { rebaseCssUrls } from "./basePath.ts";
4
+ import { currentBase } from "./appBase.ts";
5
+
6
+ /**
7
+ * Scoped `<style>` blocks harvested from every `.svelte` file the client build
8
+ * touches, keyed by absolute source path so a recompile of the same file
9
+ * replaces its rules rather than appending a second copy.
10
+ *
11
+ * Why a hand-rolled collector rather than letting Bun emit the CSS: with
12
+ * `splitting: true` a `css: "external"` compile makes Bun write one CSS sidecar
13
+ * per dynamic-imported chunk, which is the "Multiple files share the same
14
+ * output path" failure that 0.4.4 fought (see `test/svelte-build.test.ts`).
15
+ * Collecting here keeps the client build's CSS-output count at zero — the
16
+ * invariant that test pins — while still producing a real stylesheet.
17
+ */
18
+ const collected = new Map<string, string>();
19
+
20
+ export function collectComponentCss(filePath: string, css: string): void {
21
+ collected.set(filePath, css);
22
+ }
23
+
24
+ /**
25
+ * Concatenate everything collected into one content-hashed stylesheet in
26
+ * `clientDir`, and return its basename — or `null` when the app has no scoped
27
+ * styles at all, in which case nothing is written and nothing is linked.
28
+ *
29
+ * Mirrors `finalizeTailwindCss` (twHash.ts) deliberately: same rebase-then-hash
30
+ * order, same hash length, same `-<hash>.css` shape that staticManifest's
31
+ * HASHED_BASENAME rule reads as immutable.
32
+ */
33
+ export function finalizeComponentCss(clientDir: string): string | null {
34
+ if (collected.size === 0) return null;
35
+
36
+ // Sorted by path: the bundler visits modules in whatever order resolution
37
+ // happens to take, and an unstable order means an unstable hash means every
38
+ // build busts a cache that did not need busting.
39
+ const css = [...collected.keys()]
40
+ .sort()
41
+ .map((k) => collected.get(k)!)
42
+ .join("\n");
43
+
44
+ // Rebase before hashing, so the hash describes the bytes actually served —
45
+ // same reasoning as twHash: a `url(/img/x.png)` inside a component's
46
+ // `<style>` resolves against the origin and would land outside the mount.
47
+ const base = currentBase();
48
+ const bytes = base ? rebaseCssUrls(base, css) : css;
49
+
50
+ const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
51
+ const name = `bosia-css-${hash}.css`;
52
+ writeFileSync(join(clientDir, name), bytes);
53
+ return name;
54
+ }
package/src/core/html.ts CHANGED
@@ -56,6 +56,19 @@ function twCssLink(): string {
56
56
  : `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`;
57
57
  }
58
58
 
59
+ /** The build-time component stylesheet (scoped `<style>` blocks, concatenated).
60
+ * Emitted AFTER `twCssLink()` on every path: these rules used to be appended to
61
+ * `document.head` at hydration, i.e. last, and the app stylesheets Tailwind
62
+ * inlines (`tokens.css`, `components.css`) are unlayered, so a tie between them
63
+ * is settled on source order. Linking before Tailwind would silently flip
64
+ * which one wins. Each entry carries its own indent and newline, so an app with
65
+ * no scoped styles at all contributes nothing rather than a blank line. */
66
+ function componentCssLinks(): string {
67
+ return (distManifest.css ?? [])
68
+ .map((f: string) => ` <link rel="stylesheet" href="${DIST}/${f}">\n`)
69
+ .join("");
70
+ }
71
+
59
72
  /** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
60
73
  const THEME_INIT_JS =
61
74
  "try{var t=localStorage.getItem('theme');" +
@@ -153,10 +166,6 @@ export function buildHtml(
153
166
  body = rebaseHtmlAttrs(B, body);
154
167
  head = rebaseHtmlAttrs(B, head);
155
168
 
156
- const cssLinks = (distManifest.css ?? [])
157
- .map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
158
- .join("\n ");
159
-
160
169
  // Metadata goes in before `head`: the first <title> in the document wins, and
161
170
  // the streaming path already puts metadata() ahead of <svelte:head> content
162
171
  // (which arrives later via buildHtmlTail). Same order = same winner on both paths.
@@ -210,8 +219,8 @@ export function buildHtml(
210
219
 
211
220
  return (
212
221
  headOpenInterpolated +
213
- `\n ${faviconLine}${cssLinks}\n` +
214
- ` ${twCssLink()}\n` +
222
+ `\n ${faviconLine}${twCssLink()}\n` +
223
+ componentCssLinks() +
215
224
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
216
225
  ` ${fallbackTitle}${metaTags}${head}` +
217
226
  headCloseInterpolated +
@@ -229,9 +238,8 @@ export function buildHtml(
229
238
  ${fallbackTitle}
230
239
  <link rel="icon" type="image/svg+xml" href="${FAVICON}">
231
240
  ${metaTags} ${head}
232
- ${cssLinks}
233
241
  ${twCssLink()}
234
- <script${n}>${THEME_INIT_JS}</script>
242
+ ${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
235
243
  </head>
236
244
  <body>
237
245
  <div id="app">${body}</div>${scripts}${bodyEnd}
@@ -249,10 +257,6 @@ export function buildHtmlShellOpen(
249
257
  ): string {
250
258
  const key = safeLang(lang);
251
259
  const n = nonceAttr(nonce);
252
- const cssLinks = (distManifest.css ?? [])
253
- .map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
254
- .join("\n ");
255
-
256
260
  if (segments) {
257
261
  const headOpenInterpolated = interpolateSegment(segments.headOpen, { lang: key, nonce });
258
262
  const faviconLine = segments.hasCustomFavicon
@@ -260,8 +264,8 @@ export function buildHtmlShellOpen(
260
264
  : ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n`;
261
265
  return (
262
266
  headOpenInterpolated +
263
- `\n ${faviconLine}${cssLinks}\n` +
264
- ` ${twCssLink()}\n` +
267
+ `\n ${faviconLine}${twCssLink()}\n` +
268
+ componentCssLinks() +
265
269
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
266
270
  ` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
267
271
  );
@@ -272,8 +276,8 @@ export function buildHtmlShellOpen(
272
276
  ` <meta charset="UTF-8">\n` +
273
277
  ` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
274
278
  ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n` +
275
- ` ${cssLinks}\n` +
276
279
  ` ${twCssLink()}\n` +
280
+ componentCssLinks() +
277
281
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
278
282
  ` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
279
283
  );
@@ -4,6 +4,7 @@ import { relative } from "node:path";
4
4
  import type { BunPlugin } from "bun";
5
5
  import { svelteMapCache } from "../../svelteCompiler.ts";
6
6
  import { lineColFromOffset } from "../../sourceLoc.ts";
7
+ import { collectComponentCss } from "../../componentCss.ts";
7
8
 
8
9
  type AnyNode = {
9
10
  type?: string;
@@ -136,11 +137,12 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
136
137
  generate,
137
138
  dev,
138
139
  hmr: dev,
139
- // Mirror the prod compiler (svelteCompiler.ts): client injects scoped
140
- // CSS into the JS via `append_styles`, server discards it. No CSS
141
- // chunks means Bun's `splitting:true` output-path collisions can't
142
- // arise, so no runtime-injection workaround is needed.
143
- css: generate === "client" ? "injected" : "external",
140
+ // Mirror the prod compiler (svelteCompiler.ts): external on both
141
+ // targets, with the client's rules harvested into one stylesheet
142
+ // that the head links. This plugin registers ahead of the main
143
+ // compiler and its onLoad wins in dev, so letting the two drift
144
+ // here is how dev and prod stop agreeing about first paint.
145
+ css: "external",
144
146
  preserveWhitespace: dev,
145
147
  preserveComments: dev,
146
148
  cssHash: ({ css }) => `svelte-${fnv(css)}`,
@@ -165,6 +167,11 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
165
167
  svelteMapCache.set(args.path, m);
166
168
  }
167
169
 
170
+ // Client only — see the same guard in svelteCompiler.ts.
171
+ if (generate === "client" && result.css?.code) {
172
+ collectComponentCss(args.path, result.css.code);
173
+ }
174
+
168
175
  const js = dev ? fixBindShadow(result.js.code) : result.js.code;
169
176
  return { contents: js, loader: "ts" };
170
177
  });
@@ -65,6 +65,7 @@ export function scanRoutes(): RouteManifest {
65
65
  layoutServerChain: { path: string; depth: number }[],
66
66
  errorPageChain: { path: string; depth: number }[],
67
67
  inheritedTrailingSlash: TrailingSlash,
68
+ inheritedLoading: string | null,
68
69
  ) {
69
70
  const fullDir = join(ROUTES_DIR, dir);
70
71
  if (!existsSync(fullDir)) return;
@@ -76,6 +77,12 @@ export function scanRoutes(): RouteManifest {
76
77
  const currentLayoutServers = [...layoutServerChain];
77
78
  const currentErrorPages = [...errorPageChain];
78
79
  let currentTrailingSlash = inheritedTrailingSlash;
80
+ // Cascades to every page below, nearest ancestor winning — the same shape
81
+ // as the layout chain. Without this a section with 40 routes needed 40
82
+ // identical +loading.svelte files to cover its navigations.
83
+ const currentLoading = items.some((i) => i.isFile() && i.name === "+loading.svelte")
84
+ ? join(dir, "+loading.svelte")
85
+ : inheritedLoading;
79
86
 
80
87
  if (items.some((i) => i.isFile() && i.name === "+layout.svelte")) {
81
88
  currentLayouts.push(join(dir, "+layout.svelte"));
@@ -112,10 +119,6 @@ export function scanRoutes(): RouteManifest {
112
119
  ? join(dir, "+page.server.ts")
113
120
  : null;
114
121
 
115
- const loadingFile = items.some((i) => i.isFile() && i.name === "+loading.svelte")
116
- ? join(dir, "+loading.svelte")
117
- : null;
118
-
119
122
  const pageTs = pageServerFile ? readTrailingSlash(join(ROUTES_DIR, pageServerFile)) : null;
120
123
  const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
121
124
 
@@ -125,7 +128,7 @@ export function scanRoutes(): RouteManifest {
125
128
  page: pageFile,
126
129
  layouts: [...currentLayouts],
127
130
  pageServer: pageServerFile,
128
- loading: loadingFile,
131
+ loading: currentLoading,
129
132
  layoutServers: [...currentLayoutServers],
130
133
  errorPages: [...currentErrorPages],
131
134
  trailingSlash: effectiveTs,
@@ -149,11 +152,12 @@ export function scanRoutes(): RouteManifest {
149
152
  currentLayoutServers,
150
153
  currentErrorPages,
151
154
  currentTrailingSlash,
155
+ currentLoading,
152
156
  );
153
157
  }
154
158
  }
155
159
 
156
- walk("", [], [], [], [], "never");
160
+ walk("", [], [], [], [], "never", null);
157
161
 
158
162
  // Warn when a catch-all exists but no exact route covers its prefix.
159
163
  // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
@@ -2,6 +2,7 @@ import { compile, compileModule } from "svelte/compiler";
2
2
  import type { BunPlugin } from "bun";
3
3
 
4
4
  import { auditSvelteSource } from "./svelteAudit.ts";
5
+ import { collectComponentCss } from "./componentCss.ts";
5
6
  import { rebaseHtmlAttrs } from "./basePath.ts";
6
7
  import { currentBase } from "./appBase.ts";
7
8
  import { loadBosiaConfig } from "./config.ts";
@@ -110,7 +111,12 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
110
111
  const source = await Bun.file(args.path).text();
111
112
  const result = compile(rebaseSvelteMarkup(source), {
112
113
  generate,
113
- css: target === "browser" ? "injected" : "external",
114
+ // External on both targets. The browser used to get "injected",
115
+ // which put every scoped rule inside the JS bundle — so an
116
+ // SSR'd page painted before its own layout CSS existed and
117
+ // snapped into place at hydration. `collectComponentCss` below
118
+ // gathers the rules into one stylesheet the head can link.
119
+ css: "external",
114
120
  dev,
115
121
  hmr: false,
116
122
  cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
@@ -119,6 +125,12 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
119
125
  // rather than the legacy `html`. The audit walker assumes modern.
120
126
  modernAst: true,
121
127
  });
128
+ // Browser only: both plugin instances share module state and the
129
+ // client and server builds run concurrently, so collecting from
130
+ // each would emit every rule twice.
131
+ if (target === "browser" && result.css?.code) {
132
+ collectComponentCss(args.path, result.css.code);
133
+ }
122
134
  const existing = auditInflight.get(args.path);
123
135
  if (existing) {
124
136
  await existing;