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 +1 -1
- package/src/cli/create.ts +2 -1
- package/src/cli/registry.ts +1 -1
- package/src/core/build.ts +17 -8
- package/src/core/componentCss.ts +54 -0
- package/src/core/html.ts +19 -15
- package/src/core/paths.ts +18 -5
- package/src/core/plugins/inspector/bun-plugin.ts +14 -6
- package/src/core/plugins/inspector/sourcemap.ts +11 -9
- package/src/core/port.ts +30 -8
- package/src/core/routeFile.ts +2 -1
- package/src/core/routeTypes.ts +2 -1
- package/src/core/safePath.ts +2 -2
- package/src/core/scanner.ts +21 -13
- package/src/core/svelteCompiler.ts +13 -1
package/package.json
CHANGED
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);
|
package/src/cli/registry.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface InstallOptions {
|
|
|
21
21
|
// ─── Local registry resolution ────────────────────────────
|
|
22
22
|
|
|
23
23
|
export function resolveLocalRegistry(): string {
|
|
24
|
-
let dir =
|
|
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
|
|
263
|
-
|
|
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}${
|
|
214
|
-
|
|
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}${
|
|
264
|
-
|
|
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
|
|
40
|
-
|
|
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",
|
|
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):
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
5
|
-
|
|
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
|
|
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
|
+
}
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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
|
|
254
|
+
return "../src/routes/" + toPosix(routePath);
|
|
254
255
|
}
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -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
|
|
13
|
+
const parts = toPosix(filePath).split("/");
|
|
13
14
|
parts.pop();
|
|
14
15
|
return parts.join("/") || ".";
|
|
15
16
|
}
|
package/src/core/safePath.ts
CHANGED
|
@@ -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 +
|
|
13
|
+
return full.startsWith(root + sep) || full === root ? full : null;
|
|
14
14
|
}
|
package/src/core/scanner.ts
CHANGED
|
@@ -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(
|
|
92
|
+
currentLayouts.push(rel("+layout.svelte"));
|
|
82
93
|
}
|
|
83
94
|
if (items.some((i) => i.isFile() && i.name === "+layout.server.ts")) {
|
|
84
|
-
const layoutServerPath =
|
|
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:
|
|
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:
|
|
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
|
-
?
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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;
|