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 +1 -1
- package/src/core/build.ts +12 -0
- package/src/core/componentCss.ts +54 -0
- package/src/core/html.ts +19 -15
- package/src/core/plugins/inspector/bun-plugin.ts +12 -5
- package/src/core/scanner.ts +10 -6
- package/src/core/svelteCompiler.ts +13 -1
package/package.json
CHANGED
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}${
|
|
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
|
);
|
|
@@ -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):
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
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
|
});
|
package/src/core/scanner.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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;
|