bosia 0.9.5 → 0.9.7

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.7",
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/cli/create.ts CHANGED
@@ -5,6 +5,7 @@ import { spawn } from "bun";
5
5
  import * as p from "@clack/prompts";
6
6
  import { installFeature, initFeatRegistry, resolveLocalRegistry } from "./feat.ts";
7
7
  import { initAddRegistry } from "./add.ts";
8
+ import { toPosix } from "../core/paths.ts";
8
9
 
9
10
  // ─── bun x bosia@latest create <name> [--template <name>] ─
10
11
 
@@ -283,7 +284,7 @@ function copyDir(src: string, dest: string, projectName: string, isLocal: boolea
283
284
 
284
285
  if (entry.name === "package.json" && isLocal) {
285
286
  const bosiaPath = resolve(import.meta.dir, "../../");
286
- const relPath = relative(dest, bosiaPath);
287
+ const relPath = toPosix(relative(dest, bosiaPath));
287
288
  content = content.replaceAll('"^{{BOSIA_VERSION}}"', `"file:${relPath}"`);
288
289
  } else {
289
290
  content = content.replaceAll("{{BOSIA_VERSION}}", BOSIA_VERSION);
@@ -21,7 +21,7 @@ export interface InstallOptions {
21
21
  // ─── Local registry resolution ────────────────────────────
22
22
 
23
23
  export function resolveLocalRegistry(): string {
24
- let dir = dirname(new URL(import.meta.url).pathname);
24
+ let dir = import.meta.dir;
25
25
  for (let i = 0; i < 10; i++) {
26
26
  const candidate = join(dir, "registry");
27
27
  if (existsSync(join(candidate, "index.json"))) return candidate;
package/src/core/build.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { writeFileSync, readFileSync, rmSync, mkdirSync, existsSync } from "fs";
2
- import { join, relative } from "path";
2
+ import { basename, join, relative } from "path";
3
3
  import type { RouteManifest } from "./types.ts";
4
4
 
5
5
  import { scanRoutes } from "./scanner.ts";
@@ -7,10 +7,11 @@ 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";
13
- import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin } from "./paths.ts";
14
+ import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin, toPosix } from "./paths.ts";
14
15
  import { currentBase } from "./appBase.ts";
15
16
  import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
16
17
  import { loadPlugins } from "./config.ts";
@@ -250,7 +251,7 @@ const cssFiles: string[] = [];
250
251
  // `output.kind === "entry-point"` instead so we pin the actual entry.
251
252
  let clientEntry: string | null = null;
252
253
  for (const output of clientResult.outputs) {
253
- const rel = relative(`${OUT_DIR}/client`, output.path);
254
+ const rel = toPosix(relative(`${OUT_DIR}/client`, output.path)); // URL path, not fs path
254
255
  if (output.path.endsWith(".js")) jsFiles.push(rel);
255
256
  if (output.path.endsWith(".css")) cssFiles.push(rel);
256
257
  if ((output as { kind?: string }).kind === "entry-point" && output.path.endsWith(".js")) {
@@ -258,12 +259,20 @@ 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
- const serverEntry =
263
- serverResult.outputs
264
- .find((o) => o.path.endsWith("index.js"))
265
- ?.path.split("/")
266
- .pop() ?? "index.js";
274
+ const serverEntryOutput = serverResult.outputs.find((o) => o.path.endsWith("index.js"));
275
+ const serverEntry = serverEntryOutput ? basename(serverEntryOutput.path) : "index.js";
267
276
 
268
277
  // 8. Write dist/manifest.json
269
278
  mkdirSync(OUT_DIR, { recursive: true });
@@ -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
  );
package/src/core/paths.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { join, dirname } from "path";
1
+ import { join, dirname, delimiter } from "path";
2
2
  import { existsSync } from "fs";
3
3
 
4
4
  // This file lives at src/core/paths.ts → package root is ../..
@@ -26,18 +26,31 @@ const ANCESTOR_NM = collectAncestorNodeModules(dirname(BOSIA_PKG_DIR));
26
26
  const ALL_NM = [NESTED_NM, ...ANCESTOR_NM];
27
27
 
28
28
  /** NODE_PATH value covering nested and every ancestor node_modules */
29
- export const BOSIA_NODE_PATH = ALL_NM.join(":");
29
+ export const BOSIA_NODE_PATH = ALL_NM.join(delimiter); // ";" on Windows, ":" elsewhere
30
30
 
31
31
  // On-disk output directory. URL namespace (/dist/client/...) stays stable;
32
32
  // only the on-disk location moves so dev (.bosia/dev) and a parallel
33
33
  // `bun run build` (./dist) don't clobber each other.
34
34
  export const OUT_DIR = process.env.BOSIA_OUT_DIR ?? "./dist";
35
35
 
36
+ /** Normalize a filesystem path to forward slashes (for URLs, manifests, imports). */
37
+ export function toPosix(p: string): string {
38
+ return p.replace(/\\/g, "/");
39
+ }
40
+
41
+ /** `.bin` file names to try for `name`. Windows shims carry an extension. */
42
+ export function binCandidates(name: string, platform: string = process.platform): string[] {
43
+ return platform === "win32" ? [`${name}.exe`, `${name}.cmd`, `${name}.bunx`, name] : [name];
44
+ }
45
+
36
46
  /** Find a binary from bosia's dependencies (handles hoisting) */
37
47
  export function resolveBosiaBin(name: string): string {
48
+ const candidates = binCandidates(name);
38
49
  for (const nm of ALL_NM) {
39
- const bin = join(nm, ".bin", name);
40
- if (existsSync(bin)) return bin;
50
+ for (const file of candidates) {
51
+ const bin = join(nm, ".bin", file);
52
+ if (existsSync(bin)) return bin;
53
+ }
41
54
  }
42
- return join(NESTED_NM, ".bin", name); // fallback — will produce a clear ENOENT
55
+ return join(NESTED_NM, ".bin", candidates[0]); // fallback — will produce a clear ENOENT
43
56
  }
@@ -4,6 +4,8 @@ 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 { toPosix } from "../../paths.ts";
8
+ import { collectComponentCss } from "../../componentCss.ts";
7
9
 
8
10
  type AnyNode = {
9
11
  type?: string;
@@ -128,7 +130,7 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
128
130
  setup(build) {
129
131
  build.onLoad({ filter: /\.svelte$/ }, async (args) => {
130
132
  const source = await Bun.file(args.path).text();
131
- const rel = relative(cwd, args.path);
133
+ const rel = toPosix(relative(cwd, args.path));
132
134
  const transformed = injectLocs(source, rel);
133
135
 
134
136
  const result = compile(transformed, {
@@ -136,11 +138,12 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
136
138
  generate,
137
139
  dev,
138
140
  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",
141
+ // Mirror the prod compiler (svelteCompiler.ts): external on both
142
+ // targets, with the client's rules harvested into one stylesheet
143
+ // that the head links. This plugin registers ahead of the main
144
+ // compiler and its onLoad wins in dev, so letting the two drift
145
+ // here is how dev and prod stop agreeing about first paint.
146
+ css: "external",
144
147
  preserveWhitespace: dev,
145
148
  preserveComments: dev,
146
149
  cssHash: ({ css }) => `svelte-${fnv(css)}`,
@@ -165,6 +168,11 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
165
168
  svelteMapCache.set(args.path, m);
166
169
  }
167
170
 
171
+ // Client only — see the same guard in svelteCompiler.ts.
172
+ if (generate === "client" && result.css?.code) {
173
+ collectComponentCss(args.path, result.css.code);
174
+ }
175
+
168
176
  const js = dev ? fixBindShadow(result.js.code) : result.js.code;
169
177
  return { contents: js, loader: "ts" };
170
178
  });
@@ -1,7 +1,7 @@
1
1
  import { TraceMap, originalPositionFor, GREATEST_LOWER_BOUND } from "@jridgewell/trace-mapping";
2
2
  import { readFileSync, existsSync } from "node:fs";
3
- import { dirname, resolve as pathResolve } from "node:path";
4
- import { OUT_DIR } from "../../paths.ts";
3
+ import { dirname, isAbsolute, relative, resolve as pathResolve } from "node:path";
4
+ import { OUT_DIR, toPosix } from "../../paths.ts";
5
5
 
6
6
  const cache = new Map<string, TraceMap | null>();
7
7
 
@@ -64,7 +64,7 @@ function mapPathFor(file: string): string | null {
64
64
  ? OUT_DIR + pathname.slice("/dist".length)
65
65
  : "." + pathname;
66
66
  fsPath = pathResolve(process.cwd(), relFromCwd);
67
- } else if (file.startsWith("/")) {
67
+ } else if (isAbsolute(file)) {
68
68
  fsPath = file;
69
69
  } else {
70
70
  fsPath = pathResolve(process.cwd(), file);
@@ -109,16 +109,18 @@ export function resolveFrame(
109
109
  }
110
110
  if (refined.source && refined.line != null) {
111
111
  const refinedAbs = pathResolve(dirname(abs), refined.source);
112
- const rel = refinedAbs.startsWith(process.cwd() + "/")
113
- ? refinedAbs.slice(process.cwd().length + 1)
114
- : refinedAbs;
115
- return { file: rel, line: refined.line, col: refined.column ?? 1 };
112
+ return { file: relToCwd(refinedAbs), line: refined.line, col: refined.column ?? 1 };
116
113
  }
117
114
  }
118
115
  }
119
116
 
120
- const rel = abs.startsWith(process.cwd() + "/") ? abs.slice(process.cwd().length + 1) : abs;
121
- return { file: rel, line: pos.line, col: pos.column ?? 1 };
117
+ return { file: relToCwd(abs), line: pos.line, col: pos.column ?? 1 };
118
+ }
119
+
120
+ // cwd-relative "/"-separated path when `abs` is inside cwd, else `abs` unchanged.
121
+ function relToCwd(abs: string): string {
122
+ const rel = relative(process.cwd(), abs);
123
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosix(rel) : abs;
122
124
  }
123
125
 
124
126
  // Rewrite frames in stack strings: "(url:L:C)", "at url:L:C", "@url:L:C".
package/src/core/port.ts CHANGED
@@ -1,16 +1,38 @@
1
- /** PIDs listening on `port`. Fails open to [] — lsof may be absent. */
1
+ /** PIDs listening on `port`. Fails open to [] — lsof / netstat may be absent. */
2
2
  export async function pidsOnPort(port: number): Promise<number[]> {
3
+ const isWindows = process.platform === "win32";
3
4
  try {
4
- const proc = Bun.spawn(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], {
5
- stdout: "pipe",
6
- stderr: "ignore",
7
- });
5
+ const cmd = isWindows ? ["netstat", "-ano"] : ["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"];
6
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" });
8
7
  const out = await new Response(proc.stdout).text();
9
8
  await proc.exited;
10
- return [...new Set(out.split("\n").map((s) => s.trim()))]
11
- .map(Number)
12
- .filter((pid) => Number.isInteger(pid) && pid > 0);
9
+ return isWindows ? parseNetstat(out, port) : parseLsof(out);
13
10
  } catch {
14
11
  return [];
15
12
  }
16
13
  }
14
+
15
+ /** `lsof -t` output: one PID per line. */
16
+ export function parseLsof(out: string): number[] {
17
+ return uniquePids(out.split("\n").map((s) => s.trim()));
18
+ }
19
+
20
+ /**
21
+ * `netstat -ano` (Windows) rows: `TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 1234`.
22
+ * The state column is localized on non-English Windows, so a listener is
23
+ * detected by its foreign address ending in `:0` instead of the word LISTENING.
24
+ */
25
+ export function parseNetstat(out: string, port: number): number[] {
26
+ const pids: string[] = [];
27
+ for (const line of out.split(/\r?\n/)) {
28
+ const cols = line.trim().split(/\s+/);
29
+ if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue;
30
+ const [, local, foreign] = cols;
31
+ if (local.endsWith(`:${port}`) && foreign.endsWith(":0")) pids.push(cols[cols.length - 1]);
32
+ }
33
+ return uniquePids(pids);
34
+ }
35
+
36
+ function uniquePids(raw: string[]): number[] {
37
+ return [...new Set(raw)].map(Number).filter((pid) => Number.isInteger(pid) && pid > 0);
38
+ }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, mkdirSync } from "fs";
2
2
  import type { RouteManifest } from "./types.ts";
3
3
  import { currentBase } from "./appBase.ts";
4
+ import { toPosix } from "./paths.ts";
4
5
 
5
6
  /**
6
7
  * The pattern the *client* router matches against. It sees real browser URLs, so
@@ -250,5 +251,5 @@ function generateClientRoutesFile(
250
251
 
251
252
  // Import path from .bosia/routes.ts to src/routes/<routePath>
252
253
  function toImportPath(routePath: string): string {
253
- return "../src/routes/" + routePath.replace(/\\/g, "/");
254
+ return "../src/routes/" + toPosix(routePath);
254
255
  }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { RouteManifest } from "./types.ts";
4
+ import { toPosix } from "./paths.ts";
4
5
 
5
6
  // ─── Route Types Generator ────────────────────────────────
6
7
  // Generates .bosia/types/src/routes/**/$types.d.ts for each
@@ -9,7 +10,7 @@ import type { RouteManifest } from "./types.ts";
9
10
  // work in +page.svelte files — identical to SvelteKit's API.
10
11
 
11
12
  function routeDirOf(filePath: string): string {
12
- const parts = filePath.replace(/\\/g, "/").split("/");
13
+ const parts = toPosix(filePath).split("/");
13
14
  parts.pop();
14
15
  return parts.join("/") || ".";
15
16
  }
@@ -1,4 +1,4 @@
1
- import { join, resolve as resolvePath } from "path";
1
+ import { join, resolve as resolvePath, sep } from "path";
2
2
 
3
3
  /**
4
4
  * Resolve `untrusted` relative to `base` and verify the result stays inside
@@ -10,5 +10,5 @@ import { join, resolve as resolvePath } from "path";
10
10
  export function safePath(base: string, untrusted: string): string | null {
11
11
  const root = resolvePath(base);
12
12
  const full = resolvePath(join(base, untrusted));
13
- return full.startsWith(root + "/") || full === root ? full : null;
13
+ return full.startsWith(root + sep) || full === root ? full : null;
14
14
  }
@@ -65,10 +65,15 @@ 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;
71
72
 
73
+ // Manifest paths are always "/"-separated (they become import specifiers),
74
+ // so build them by hand — path.join would emit "\" on Windows.
75
+ const rel = (name: string) => (dir ? `${dir}/${name}` : name);
76
+
72
77
  const items = readdirSync(fullDir, { withFileTypes: true });
73
78
 
74
79
  // Accumulate layouts for this level
@@ -76,12 +81,18 @@ export function scanRoutes(): RouteManifest {
76
81
  const currentLayoutServers = [...layoutServerChain];
77
82
  const currentErrorPages = [...errorPageChain];
78
83
  let currentTrailingSlash = inheritedTrailingSlash;
84
+ // Cascades to every page below, nearest ancestor winning — the same shape
85
+ // as the layout chain. Without this a section with 40 routes needed 40
86
+ // identical +loading.svelte files to cover its navigations.
87
+ const currentLoading = items.some((i) => i.isFile() && i.name === "+loading.svelte")
88
+ ? rel("+loading.svelte")
89
+ : inheritedLoading;
79
90
 
80
91
  if (items.some((i) => i.isFile() && i.name === "+layout.svelte")) {
81
- currentLayouts.push(join(dir, "+layout.svelte"));
92
+ currentLayouts.push(rel("+layout.svelte"));
82
93
  }
83
94
  if (items.some((i) => i.isFile() && i.name === "+layout.server.ts")) {
84
- const layoutServerPath = join(dir, "+layout.server.ts");
95
+ const layoutServerPath = rel("+layout.server.ts");
85
96
  currentLayoutServers.push({
86
97
  path: layoutServerPath,
87
98
  depth: currentLayouts.length - 1,
@@ -93,7 +104,7 @@ export function scanRoutes(): RouteManifest {
93
104
  // depth = number of layouts wrapping this dir (this dir's layout included).
94
105
  // An error page at depth K renders inside layouts[0..K-1].
95
106
  currentErrorPages.push({
96
- path: join(dir, "+error.svelte"),
107
+ path: rel("+error.svelte"),
97
108
  depth: currentLayouts.length,
98
109
  });
99
110
  }
@@ -102,30 +113,26 @@ export function scanRoutes(): RouteManifest {
102
113
  if (items.some((i) => i.isFile() && i.name === "+server.ts")) {
103
114
  apis.push({
104
115
  pattern: toUrlPath(urlSegments),
105
- server: join(dir, "+server.ts"),
116
+ server: rel("+server.ts"),
106
117
  });
107
118
  }
108
119
 
109
120
  // Page route (+page.svelte)
110
121
  if (items.some((i) => i.isFile() && i.name === "+page.svelte")) {
111
122
  const pageServerFile = items.some((i) => i.isFile() && i.name === "+page.server.ts")
112
- ? join(dir, "+page.server.ts")
113
- : null;
114
-
115
- const loadingFile = items.some((i) => i.isFile() && i.name === "+loading.svelte")
116
- ? join(dir, "+loading.svelte")
123
+ ? rel("+page.server.ts")
117
124
  : null;
118
125
 
119
126
  const pageTs = pageServerFile ? readTrailingSlash(join(ROUTES_DIR, pageServerFile)) : null;
120
127
  const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
121
128
 
122
- const pageFile = join(dir, "+page.svelte");
129
+ const pageFile = rel("+page.svelte");
123
130
  pages.push({
124
131
  pattern: toUrlPath(urlSegments),
125
132
  page: pageFile,
126
133
  layouts: [...currentLayouts],
127
134
  pageServer: pageServerFile,
128
- loading: loadingFile,
135
+ loading: currentLoading,
129
136
  layoutServers: [...currentLayoutServers],
130
137
  errorPages: [...currentErrorPages],
131
138
  trailingSlash: effectiveTs,
@@ -143,17 +150,18 @@ export function scanRoutes(): RouteManifest {
143
150
  const isGroup = /^\(.*\)$/.test(dirName);
144
151
 
145
152
  walk(
146
- dir ? join(dir, dirName) : dirName,
153
+ rel(dirName),
147
154
  isGroup ? [...urlSegments] : [...urlSegments, dirName],
148
155
  currentLayouts,
149
156
  currentLayoutServers,
150
157
  currentErrorPages,
151
158
  currentTrailingSlash,
159
+ currentLoading,
152
160
  );
153
161
  }
154
162
  }
155
163
 
156
- walk("", [], [], [], [], "never");
164
+ walk("", [], [], [], [], "never", null);
157
165
 
158
166
  // Warn when a catch-all exists but no exact route covers its prefix.
159
167
  // 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;