bosia 0.8.4 → 0.8.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 -3
- package/src/core/cache.ts +12 -6
- package/src/core/client/App.svelte +22 -17
- package/src/core/client/router.svelte.ts +54 -4
- package/src/core/dev.ts +52 -4
- package/src/core/html.ts +13 -5
- package/src/core/plugin.ts +2 -2
- package/src/core/plugins/inspector/bun-plugin.ts +7 -36
- package/src/core/prerender.ts +4 -3
- package/src/core/server.ts +4 -1
- package/src/core/twHash.ts +18 -0
package/package.json
CHANGED
package/src/core/build.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
|
|
|
10
10
|
import { loadEnv, classifyEnvVars } from "./env.ts";
|
|
11
11
|
import { generateEnvModules } from "./envCodegen.ts";
|
|
12
12
|
import { BOSIA_NODE_PATH, OUT_DIR, resolveBosiaBin } from "./paths.ts";
|
|
13
|
+
import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
|
|
13
14
|
import { loadPlugins } from "./config.ts";
|
|
14
15
|
import type { BuildContext } from "./types/plugin.ts";
|
|
15
16
|
import { loadAppHtmlTemplate, writeAppHtmlSegments } from "./appHtml.ts";
|
|
@@ -113,15 +114,21 @@ ensureRootDirs();
|
|
|
113
114
|
// 2d. Generate .bosia/env.server.ts, .bosia/env.client.ts, .bosia/types/env.d.ts
|
|
114
115
|
generateEnvModules(classifiedEnv);
|
|
115
116
|
|
|
116
|
-
// 3. Start Tailwind CSS (async — runs concurrently with client+server builds)
|
|
117
|
+
// 3. Start Tailwind CSS (async — runs concurrently with client+server builds).
|
|
118
|
+
// Output goes to a temp name in dist/client; after the build we content-hash it
|
|
119
|
+
// and rename to bosia-tw-<hash>.css so it gets immutable caching and rebuilds
|
|
120
|
+
// bust browser caches only when the CSS actually changed. mkdir up front —
|
|
121
|
+
// Bun.build writes the same dir concurrently and mkdir is idempotent.
|
|
122
|
+
mkdirSync(join(OUT_DIR, "client"), { recursive: true });
|
|
117
123
|
const tailwindBin = resolveBosiaBin("tailwindcss");
|
|
124
|
+
const tailwindTempPath = join(OUT_DIR, "client", TW_TEMP_BASENAME);
|
|
118
125
|
const tailwindProc = Bun.spawn(
|
|
119
126
|
[
|
|
120
127
|
tailwindBin,
|
|
121
128
|
"-i",
|
|
122
129
|
"./src/app.css",
|
|
123
130
|
"-o",
|
|
124
|
-
|
|
131
|
+
tailwindTempPath,
|
|
125
132
|
...(isProduction ? ["--minify"] : []),
|
|
126
133
|
],
|
|
127
134
|
{
|
|
@@ -191,7 +198,8 @@ if (tailwindExitCode !== 0) {
|
|
|
191
198
|
console.error("❌ Tailwind CSS build failed:\n" + stderr);
|
|
192
199
|
process.exit(1);
|
|
193
200
|
}
|
|
194
|
-
|
|
201
|
+
const twFile = finalizeTailwindCss(tailwindTempPath);
|
|
202
|
+
console.log(`✅ Tailwind CSS built: ${OUT_DIR}/client/${twFile}`);
|
|
195
203
|
|
|
196
204
|
if (!clientResult.success) {
|
|
197
205
|
console.error("❌ Client build failed:");
|
|
@@ -253,6 +261,7 @@ const distManifest = {
|
|
|
253
261
|
jsFiles.find((f) => f.startsWith("hydrate")) ??
|
|
254
262
|
"hydrate.js",
|
|
255
263
|
serverEntry,
|
|
264
|
+
tw: twFile,
|
|
256
265
|
};
|
|
257
266
|
writeFileSync(`${OUT_DIR}/manifest.json`, JSON.stringify(distManifest, null, 2));
|
|
258
267
|
console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
|
package/src/core/cache.ts
CHANGED
|
@@ -88,14 +88,15 @@ class LRU<K, V> {
|
|
|
88
88
|
this.map.set(key, v);
|
|
89
89
|
return v;
|
|
90
90
|
}
|
|
91
|
-
set(key: K, value: V): K | undefined {
|
|
91
|
+
set(key: K, value: V): { key: K; value: V } | undefined {
|
|
92
92
|
if (this.map.has(key)) this.map.delete(key);
|
|
93
93
|
this.map.set(key, value);
|
|
94
94
|
if (this.map.size > this.cap) {
|
|
95
95
|
const oldest = this.map.keys().next().value as K | undefined;
|
|
96
96
|
if (oldest !== undefined) {
|
|
97
|
+
const evicted = this.map.get(oldest) as V;
|
|
97
98
|
this.map.delete(oldest);
|
|
98
|
-
return oldest;
|
|
99
|
+
return { key: oldest, value: evicted };
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
return undefined;
|
|
@@ -217,7 +218,7 @@ export function cacheSet(key: string, entry: CacheEntry, cookies?: Cookies): voi
|
|
|
217
218
|
// Drop any existing entry's index pointers first
|
|
218
219
|
cacheDeleteKey(key);
|
|
219
220
|
const evicted = htmlCache.set(key, entry);
|
|
220
|
-
if (evicted) cacheDeleteIndexOnly(evicted);
|
|
221
|
+
if (evicted) cacheDeleteIndexOnly(evicted.key, evicted.value);
|
|
221
222
|
for (const tag of entry.tags) {
|
|
222
223
|
let set = tagIndex.get(tag);
|
|
223
224
|
if (!set) {
|
|
@@ -257,9 +258,14 @@ function cacheDeleteKey(key: string): void {
|
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
/** Cleanup index pointers for a key after LRU evicted it. */
|
|
260
|
-
function cacheDeleteIndexOnly(key: string): void {
|
|
261
|
-
for (const
|
|
262
|
-
|
|
261
|
+
function cacheDeleteIndexOnly(key: string, entry: CacheEntry): void {
|
|
262
|
+
for (const tag of entry.tags) {
|
|
263
|
+
const set = tagIndex.get(tag);
|
|
264
|
+
if (set) {
|
|
265
|
+
set.delete(key);
|
|
266
|
+
if (set.size === 0) tagIndex.delete(tag);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
263
269
|
const path = pathOfKey(key);
|
|
264
270
|
const pset = pathIndex.get(path);
|
|
265
271
|
if (pset) {
|
|
@@ -63,6 +63,26 @@
|
|
|
63
63
|
let firstNav = true;
|
|
64
64
|
let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
65
65
|
|
|
66
|
+
// Scroll after a nav settles: forward navs go to top (or the URL hash);
|
|
67
|
+
// back/forward navs restore the position saved for that history entry.
|
|
68
|
+
// Runs after tick() so the destination page's real height is in the DOM.
|
|
69
|
+
// goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
|
|
70
|
+
function settleScroll() {
|
|
71
|
+
if (router.isPush && !router.suppressScroll) {
|
|
72
|
+
const hash = window.location.hash;
|
|
73
|
+
tick().then(() => {
|
|
74
|
+
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
75
|
+
});
|
|
76
|
+
} else if (!router.isPush) {
|
|
77
|
+
const pos = router.pendingScroll;
|
|
78
|
+
// ponytail: single post-tick restore; content that loads later (images
|
|
79
|
+
// without dimensions) can still shift it. Revisit only if reported.
|
|
80
|
+
if (pos) tick().then(() => window.scrollTo(pos.x, pos.y));
|
|
81
|
+
}
|
|
82
|
+
router.pendingScroll = null;
|
|
83
|
+
router.suppressScroll = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
$effect(() => {
|
|
67
87
|
if (ssrMode) return;
|
|
68
88
|
|
|
@@ -247,13 +267,7 @@
|
|
|
247
267
|
appState.errorComponent = errMod.default;
|
|
248
268
|
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
249
269
|
appState.errorDepth = K;
|
|
250
|
-
|
|
251
|
-
const hash = window.location.hash;
|
|
252
|
-
tick().then(() => {
|
|
253
|
-
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
router.suppressScroll = false;
|
|
270
|
+
settleScroll();
|
|
257
271
|
settle({ url, params: match.params });
|
|
258
272
|
} catch {
|
|
259
273
|
window.location.href = path;
|
|
@@ -342,16 +356,7 @@
|
|
|
342
356
|
appState.errorProps = null;
|
|
343
357
|
appState.errorDepth = null;
|
|
344
358
|
|
|
345
|
-
|
|
346
|
-
// goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
|
|
347
|
-
// If the destination URL has a hash, scroll to that element instead.
|
|
348
|
-
if (router.isPush && !router.suppressScroll) {
|
|
349
|
-
const hash = window.location.hash;
|
|
350
|
-
tick().then(() => {
|
|
351
|
-
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
router.suppressScroll = false;
|
|
359
|
+
settleScroll();
|
|
355
360
|
|
|
356
361
|
// Update document title and meta description from server metadata
|
|
357
362
|
if (result?.metadata) {
|
|
@@ -16,6 +16,20 @@ function buildTarget(path: string): { url: URL; params: Record<string, string> }
|
|
|
16
16
|
return { url, params: match?.params ?? {} };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// ─── Scroll restoration ───
|
|
20
|
+
// Positions keyed by a per-entry index stamped into `history.state.bosiaIndex`.
|
|
21
|
+
// `scrollRestoration = "manual"` because the browser restores at popstate time,
|
|
22
|
+
// before the SPA has rendered the destination page — it clamps against the
|
|
23
|
+
// wrong document height. We restore ourselves after the nav settles (App.svelte).
|
|
24
|
+
// Persisted to sessionStorage on unload so reload / back-into-app still restores.
|
|
25
|
+
const SCROLL_KEY = "bosia:scroll";
|
|
26
|
+
let scrollPositions: Record<number, { x: number; y: number }> = {};
|
|
27
|
+
let historyIndex = 0;
|
|
28
|
+
|
|
29
|
+
function saveScroll() {
|
|
30
|
+
scrollPositions[historyIndex] = { x: window.scrollX, y: window.scrollY };
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export function scrollToHash(hash: string): boolean {
|
|
20
34
|
if (typeof document === "undefined" || !hash) return false;
|
|
21
35
|
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
@@ -45,6 +59,8 @@ export const router = new (class Router {
|
|
|
45
59
|
lastNavType: NavType = "enter";
|
|
46
60
|
/** Set by `goto({ noScroll: true })`; consumed once by App.svelte after the next nav settles. */
|
|
47
61
|
suppressScroll = false;
|
|
62
|
+
/** Saved scroll position for the entry a popstate landed on; consumed once by App.svelte after the nav settles. */
|
|
63
|
+
pendingScroll: { x: number; y: number } | null = null;
|
|
48
64
|
|
|
49
65
|
navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}) {
|
|
50
66
|
if (this.currentRoute === path) return;
|
|
@@ -74,12 +90,15 @@ export const router = new (class Router {
|
|
|
74
90
|
|
|
75
91
|
this.lastNavType = navType;
|
|
76
92
|
this.isPush = true;
|
|
93
|
+
this.pendingScroll = null;
|
|
77
94
|
this.currentRoute = finalPath;
|
|
78
95
|
if (typeof history !== "undefined") {
|
|
79
96
|
if (opts.replace) {
|
|
80
|
-
history.replaceState({}, "", finalPath);
|
|
97
|
+
history.replaceState({ bosiaIndex: historyIndex }, "", finalPath);
|
|
81
98
|
} else {
|
|
82
|
-
|
|
99
|
+
saveScroll();
|
|
100
|
+
historyIndex++;
|
|
101
|
+
history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
|
|
83
102
|
}
|
|
84
103
|
}
|
|
85
104
|
}
|
|
@@ -87,6 +106,22 @@ export const router = new (class Router {
|
|
|
87
106
|
init() {
|
|
88
107
|
if (typeof window === "undefined") return;
|
|
89
108
|
|
|
109
|
+
history.scrollRestoration = "manual";
|
|
110
|
+
try {
|
|
111
|
+
scrollPositions = JSON.parse(sessionStorage.getItem(SCROLL_KEY) ?? "{}");
|
|
112
|
+
} catch {
|
|
113
|
+
scrollPositions = {};
|
|
114
|
+
}
|
|
115
|
+
const stamped = history.state?.bosiaIndex != null;
|
|
116
|
+
historyIndex = stamped ? history.state.bosiaIndex : 0;
|
|
117
|
+
history.replaceState({ ...history.state, bosiaIndex: historyIndex }, "");
|
|
118
|
+
// Restore after a reload — manual mode means the browser won't. Only for
|
|
119
|
+
// entries we stamped before (a fresh visit has no bosiaIndex in state).
|
|
120
|
+
const initial = scrollPositions[historyIndex];
|
|
121
|
+
if (stamped && initial) {
|
|
122
|
+
requestAnimationFrame(() => window.scrollTo(initial.x, initial.y));
|
|
123
|
+
}
|
|
124
|
+
|
|
90
125
|
// Intercept <a> clicks for client-side navigation
|
|
91
126
|
window.addEventListener("click", (e) => {
|
|
92
127
|
// Let browser handle non-primary buttons, modifier-clicks, already-handled events
|
|
@@ -110,7 +145,9 @@ export const router = new (class Router {
|
|
|
110
145
|
e.preventDefault();
|
|
111
146
|
const finalPath = anchor.pathname + anchor.search + anchor.hash;
|
|
112
147
|
if (this.currentRoute !== finalPath) {
|
|
113
|
-
|
|
148
|
+
saveScroll();
|
|
149
|
+
historyIndex++;
|
|
150
|
+
history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
|
|
114
151
|
this.currentRoute = finalPath;
|
|
115
152
|
}
|
|
116
153
|
scrollToHash(anchor.hash);
|
|
@@ -122,8 +159,13 @@ export const router = new (class Router {
|
|
|
122
159
|
});
|
|
123
160
|
|
|
124
161
|
// Browser back/forward
|
|
125
|
-
window.addEventListener("popstate", () => {
|
|
162
|
+
window.addEventListener("popstate", (e) => {
|
|
126
163
|
const finalPath = window.location.pathname + window.location.search + window.location.hash;
|
|
164
|
+
// Save the position of the entry we're leaving (historyIndex is still
|
|
165
|
+
// the old entry's), then look up where the landed-on entry was.
|
|
166
|
+
saveScroll();
|
|
167
|
+
historyIndex = e.state?.bosiaIndex ?? 0;
|
|
168
|
+
this.pendingScroll = scrollPositions[historyIndex] ?? null;
|
|
127
169
|
// Fire beforeNavigate listeners; popstate can't be reliably cancelled
|
|
128
170
|
// (browser history already advanced), so we surface the event for
|
|
129
171
|
// observation only — `cancel()` is a no-op for this source.
|
|
@@ -147,6 +189,14 @@ export const router = new (class Router {
|
|
|
147
189
|
// listeners can warn-on-leave (return value ignored; cancellation here
|
|
148
190
|
// requires `beforeunload`, not in scope).
|
|
149
191
|
window.addEventListener("beforeunload", () => {
|
|
192
|
+
// ponytail: beforeunload can be skipped on mobile page-discard; add a
|
|
193
|
+
// visibilitychange persist if restore-after-reload proves flaky there.
|
|
194
|
+
saveScroll();
|
|
195
|
+
try {
|
|
196
|
+
sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollPositions));
|
|
197
|
+
} catch {
|
|
198
|
+
// sessionStorage unavailable (private mode / quota) — skip persistence.
|
|
199
|
+
}
|
|
150
200
|
const fromTarget = buildTarget(this.currentRoute);
|
|
151
201
|
const nav: Navigation = {
|
|
152
202
|
from: fromTarget,
|
package/src/core/dev.ts
CHANGED
|
@@ -138,11 +138,50 @@ async function runBuild(): Promise<boolean> {
|
|
|
138
138
|
const DEV_PORT = Number(process.env.PORT) || 9000;
|
|
139
139
|
const APP_PORT = DEV_PORT + 1; // internal, hidden from user
|
|
140
140
|
|
|
141
|
+
// A previous dev session's app-server child can outlive its parent: an unclean
|
|
142
|
+
// stop (SIGKILL, IDE stop button, crash) skips graceful cleanup, or a shutdown
|
|
143
|
+
// slower than the SIGTERM grace window orphans it. The orphan keeps APP_PORT
|
|
144
|
+
// bound and answers the new proxy with bundle hashes that no longer exist on
|
|
145
|
+
// disk → "ENOENT reading +page-<hash>.js" 500s. Reap whatever is listening on
|
|
146
|
+
// APP_PORT before we spawn, so a stale child can never shadow the fresh build.
|
|
147
|
+
async function reapStaleAppServer(port: number) {
|
|
148
|
+
try {
|
|
149
|
+
const proc = spawn(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], {
|
|
150
|
+
stdout: "pipe",
|
|
151
|
+
stderr: "ignore",
|
|
152
|
+
});
|
|
153
|
+
const out = await new Response(proc.stdout).text();
|
|
154
|
+
await proc.exited;
|
|
155
|
+
const pids = [...new Set(out.split("\n").map((s) => s.trim()))]
|
|
156
|
+
.map(Number)
|
|
157
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
|
158
|
+
for (const pid of pids) {
|
|
159
|
+
try {
|
|
160
|
+
process.kill(pid, "SIGKILL");
|
|
161
|
+
console.log(`🧹 Reaped stale app server holding port ${port} (pid ${pid})`);
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
if (pids.length) await Bun.sleep(200); // let the port free before we bind
|
|
165
|
+
} catch {
|
|
166
|
+
// lsof unavailable or errored — fail open; a real conflict surfaces on bind.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
141
170
|
async function startAppServer() {
|
|
142
171
|
if (appProcess) {
|
|
143
172
|
intentionalKill = true;
|
|
144
|
-
appProcess.kill();
|
|
145
|
-
await
|
|
173
|
+
appProcess.kill("SIGTERM");
|
|
174
|
+
// Escalate to SIGKILL if the child won't stop — otherwise `await exited`
|
|
175
|
+
// hangs the rebuild forever, or (on a slow exit) the child lingers on
|
|
176
|
+
// APP_PORT and shadows the next spawn.
|
|
177
|
+
const exited = await Promise.race([
|
|
178
|
+
appProcess.exited.then(() => true),
|
|
179
|
+
Bun.sleep(3_000).then(() => false),
|
|
180
|
+
]);
|
|
181
|
+
if (!exited) {
|
|
182
|
+
appProcess.kill("SIGKILL");
|
|
183
|
+
await appProcess.exited;
|
|
184
|
+
}
|
|
146
185
|
intentionalKill = false;
|
|
147
186
|
}
|
|
148
187
|
|
|
@@ -421,6 +460,9 @@ const devServer = Bun.serve({
|
|
|
421
460
|
|
|
422
461
|
// ─── Initial Build ────────────────────────────────────────
|
|
423
462
|
|
|
463
|
+
// Clear any orphaned app server from a previous session before the first spawn.
|
|
464
|
+
await reapStaleAppServer(APP_PORT);
|
|
465
|
+
|
|
424
466
|
await buildAndRestart();
|
|
425
467
|
|
|
426
468
|
console.log(`\n🌐 Open http://localhost:${DEV_PORT}\n`);
|
|
@@ -428,7 +470,7 @@ console.log(`\n🌐 Open http://localhost:${DEV_PORT}\n`);
|
|
|
428
470
|
// ─── File Watcher ─────────────────────────────────────────
|
|
429
471
|
// Watch src/ recursively. Skip generated files to avoid loops.
|
|
430
472
|
|
|
431
|
-
const GENERATED = [join(process.cwd(), ".bosia")
|
|
473
|
+
const GENERATED = [join(process.cwd(), ".bosia")];
|
|
432
474
|
|
|
433
475
|
function isGenerated(path: string): boolean {
|
|
434
476
|
return GENERATED.some((g) => path.startsWith(g));
|
|
@@ -586,7 +628,13 @@ async function shutdown() {
|
|
|
586
628
|
|
|
587
629
|
if (appProcess) {
|
|
588
630
|
appProcess.kill("SIGTERM");
|
|
589
|
-
await Promise.race([
|
|
631
|
+
const exited = await Promise.race([
|
|
632
|
+
appProcess.exited.then(() => true),
|
|
633
|
+
Bun.sleep(2_500).then(() => false),
|
|
634
|
+
]);
|
|
635
|
+
// Don't orphan a slow-to-stop child (it would keep APP_PORT bound and
|
|
636
|
+
// break the next `bun run dev`). Force it down before we exit.
|
|
637
|
+
if (!exited) appProcess.kill("SIGKILL");
|
|
590
638
|
}
|
|
591
639
|
|
|
592
640
|
// Everything is stopped — exit now rather than waiting for the loop to drain.
|
package/src/core/html.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { interpolateSegment } from "./appHtml.ts";
|
|
|
9
9
|
// Maps hashed filenames → script/link tags.
|
|
10
10
|
// Cached at startup; server restarts on rebuild in dev anyway.
|
|
11
11
|
|
|
12
|
-
export const distManifest: { js: string[]; css: string[]; entry: string } = (() => {
|
|
12
|
+
export const distManifest: { js: string[]; css: string[]; entry: string; tw?: string } = (() => {
|
|
13
13
|
const p = `${OUT_DIR}/manifest.json`;
|
|
14
14
|
return existsSync(p)
|
|
15
15
|
? JSON.parse(readFileSync(p, "utf-8"))
|
|
@@ -19,6 +19,14 @@ export const distManifest: { js: string[]; css: string[]; entry: string } = (()
|
|
|
19
19
|
export const isDev = process.env.NODE_ENV !== "production";
|
|
20
20
|
const cacheBust = isDev ? `?v=${Date.now()}` : "";
|
|
21
21
|
|
|
22
|
+
/** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
|
|
23
|
+
* hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
|
|
24
|
+
function twCssLink(): string {
|
|
25
|
+
return distManifest.tw
|
|
26
|
+
? `<link rel="stylesheet" href="/dist/client/${distManifest.tw}">`
|
|
27
|
+
: `<link rel="stylesheet" href="/bosia-tw.css${cacheBust}">`;
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
/** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
|
|
23
31
|
const THEME_INIT_JS =
|
|
24
32
|
"try{var t=localStorage.getItem('theme');" +
|
|
@@ -161,7 +169,7 @@ export function buildHtml(
|
|
|
161
169
|
return (
|
|
162
170
|
headOpenInterpolated +
|
|
163
171
|
`\n ${faviconLine}${cssLinks}\n` +
|
|
164
|
-
`
|
|
172
|
+
` ${twCssLink()}\n` +
|
|
165
173
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
166
174
|
` ${fallbackTitle}${head}` +
|
|
167
175
|
headCloseInterpolated +
|
|
@@ -180,7 +188,7 @@ export function buildHtml(
|
|
|
180
188
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
181
189
|
${head}
|
|
182
190
|
${cssLinks}
|
|
183
|
-
|
|
191
|
+
${twCssLink()}
|
|
184
192
|
<script${n}>${THEME_INIT_JS}</script>
|
|
185
193
|
</head>
|
|
186
194
|
<body>
|
|
@@ -213,7 +221,7 @@ export function buildHtmlShellOpen(
|
|
|
213
221
|
return (
|
|
214
222
|
headOpenInterpolated +
|
|
215
223
|
`\n ${faviconLine}${cssLinks}\n` +
|
|
216
|
-
`
|
|
224
|
+
` ${twCssLink()}\n` +
|
|
217
225
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
218
226
|
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
|
|
219
227
|
);
|
|
@@ -225,7 +233,7 @@ export function buildHtmlShellOpen(
|
|
|
225
233
|
` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
226
234
|
` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n` +
|
|
227
235
|
` ${cssLinks}\n` +
|
|
228
|
-
`
|
|
236
|
+
` ${twCssLink()}\n` +
|
|
229
237
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
230
238
|
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
|
|
231
239
|
);
|
package/src/core/plugin.ts
CHANGED
|
@@ -82,13 +82,13 @@ export function makeBosiaPlugin(target: "browser" | "bun" = "bun") {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
// "tailwindcss" inside app.css is a Tailwind CLI directive —
|
|
85
|
-
// it's already compiled to
|
|
85
|
+
// it's already compiled to dist/client/bosia-tw-<hash>.css by the CLI step.
|
|
86
86
|
// Return an empty CSS module so Bun's CSS bundler doesn't choke on it.
|
|
87
87
|
build.onResolve({ filter: /^tailwindcss$/ }, () => ({
|
|
88
88
|
path: "tailwindcss",
|
|
89
89
|
namespace: "bosia-empty-css",
|
|
90
90
|
}));
|
|
91
|
-
// app.css is processed by Tailwind CLI into
|
|
91
|
+
// app.css is processed by Tailwind CLI into dist/client/bosia-tw-<hash>.css and
|
|
92
92
|
// loaded via <link> tag in HTML. User layouts often `import "../app.css"`
|
|
93
93
|
// for IDE/Tailwind tooling — bundle as JS no-op so Bun doesn't emit a
|
|
94
94
|
// CSS chunk per dynamic-imported route (identical content → output
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { parse, compile } from "svelte/compiler";
|
|
2
2
|
import MagicString from "magic-string";
|
|
3
|
-
import {
|
|
3
|
+
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
7
|
|
|
8
|
-
const VIRTUAL_NS = "bosia-inspector-css";
|
|
9
|
-
|
|
10
8
|
type AnyNode = {
|
|
11
9
|
type?: string;
|
|
12
10
|
name?: string;
|
|
@@ -124,7 +122,6 @@ const fnv = (s: string): string => {
|
|
|
124
122
|
export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPlugin {
|
|
125
123
|
const { cwd, target, dev } = opts;
|
|
126
124
|
const generate: "client" | "server" = target === "browser" ? "client" : "server";
|
|
127
|
-
const virtualCss = new Map<string, string>();
|
|
128
125
|
|
|
129
126
|
return {
|
|
130
127
|
name: "bosia-inspector",
|
|
@@ -139,7 +136,11 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
139
136
|
generate,
|
|
140
137
|
dev,
|
|
141
138
|
hmr: dev,
|
|
142
|
-
|
|
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",
|
|
143
144
|
preserveWhitespace: dev,
|
|
144
145
|
preserveComments: dev,
|
|
145
146
|
cssHash: ({ css }) => `svelte-${fnv(css)}`,
|
|
@@ -164,39 +165,9 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
164
165
|
svelteMapCache.set(args.path, m);
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
|
|
168
|
-
// Inject component <style> blocks via runtime JS, not CSS chunks.
|
|
169
|
-
// Bun's `splitting: true` names CSS chunks after the importing JS
|
|
170
|
-
// chunk's `[name]`, not the virtual module's uid — so when several
|
|
171
|
-
// routes (e.g. multiple `+page.svelte`) transitively import the same
|
|
172
|
-
// styled component, each route emits its own CSS sidecar named
|
|
173
|
-
// `+page-<contentHash>.css`. Identical content → identical hash →
|
|
174
|
-
// "Multiple files share the same output path". Runtime injection
|
|
175
|
-
// avoids CSS chunking entirely.
|
|
176
|
-
if (result.css?.code?.trim() && generate !== "server") {
|
|
177
|
-
const safeBase = basename(args.path).replace(/\./g, "_");
|
|
178
|
-
const uid = `${safeBase}-${fnv(args.path)}-style.css`;
|
|
179
|
-
const virtualName = `${VIRTUAL_NS}:${uid}`;
|
|
180
|
-
virtualCss.set(virtualName, result.css.code);
|
|
181
|
-
js += `\nimport ${JSON.stringify(virtualName)};`;
|
|
182
|
-
}
|
|
183
|
-
|
|
168
|
+
const js = dev ? fixBindShadow(result.js.code) : result.js.code;
|
|
184
169
|
return { contents: js, loader: "ts" };
|
|
185
170
|
});
|
|
186
|
-
|
|
187
|
-
build.onResolve({ filter: new RegExp(`^${VIRTUAL_NS}:`) }, (args) => ({
|
|
188
|
-
path: args.path,
|
|
189
|
-
namespace: VIRTUAL_NS,
|
|
190
|
-
}));
|
|
191
|
-
|
|
192
|
-
build.onLoad({ filter: /.*/, namespace: VIRTUAL_NS }, (args) => {
|
|
193
|
-
const css = virtualCss.get(args.path) ?? "";
|
|
194
|
-
virtualCss.delete(args.path);
|
|
195
|
-
const contents = css
|
|
196
|
-
? `(()=>{const s=document.createElement('style');s.textContent=${JSON.stringify(css)};document.head.appendChild(s);})();`
|
|
197
|
-
: "";
|
|
198
|
-
return { contents, loader: "js" };
|
|
199
|
-
});
|
|
200
171
|
},
|
|
201
172
|
};
|
|
202
173
|
}
|
package/src/core/prerender.ts
CHANGED
|
@@ -300,8 +300,9 @@ export function generateStaticSite(): void {
|
|
|
300
300
|
|
|
301
301
|
// Mirror `public/` → `dist/static/` on every build (not only SSG builds) so
|
|
302
302
|
// production containers can ship dist/ alone. Without this, apps with zero
|
|
303
|
-
// prerendered routes (pure SSR) would lose
|
|
304
|
-
// public/ is dropped from the image.
|
|
303
|
+
// prerendered routes (pure SSR) would lose favicons and other public assets
|
|
304
|
+
// when public/ is dropped from the image. (bosia-tw-<hash>.css lives in
|
|
305
|
+
// dist/client/ and is served from the client walk, no mirror needed.)
|
|
305
306
|
if (!hasPublic && !hasPrerender) {
|
|
306
307
|
console.log("\n⏭️ No public/ or prerendered pages — skipping static site output");
|
|
307
308
|
return;
|
|
@@ -310,7 +311,7 @@ export function generateStaticSite(): void {
|
|
|
310
311
|
console.log("\n📦 Generating static site...");
|
|
311
312
|
mkdirSync(`${OUT_DIR}/static`, { recursive: true });
|
|
312
313
|
|
|
313
|
-
// 1. Public assets (
|
|
314
|
+
// 1. Public assets (favicon, etc.) — preserves absolute /... paths
|
|
314
315
|
if (hasPublic) {
|
|
315
316
|
cpSync("./public", `${OUT_DIR}/static`, { recursive: true });
|
|
316
317
|
}
|
package/src/core/server.ts
CHANGED
|
@@ -1141,7 +1141,10 @@ app.listen(PORT, () => {
|
|
|
1141
1141
|
`\n🔑 Response cache tells users apart ONLY by these cookies/headers: [${CACHE_KEYS.join(", ")}]\n` +
|
|
1142
1142
|
` Using a different session cookie or auth header? Add its name to CACHE_KEYS,\n` +
|
|
1143
1143
|
` or one user's personalised page can be served to another. Routes personalised\n` +
|
|
1144
|
-
` by anything else should set \`export const cache = false\`.\n
|
|
1144
|
+
` by anything else should set \`export const cache = false\`.\n` +
|
|
1145
|
+
` Note: the runtime auto-warns only on uncovered *cookie* reads — it CANNOT\n` +
|
|
1146
|
+
` detect header-based personalisation, so custom auth headers (X-Api-Key,\n` +
|
|
1147
|
+
` X-Auth-Token, …) must be added to CACHE_KEYS by hand.\n`,
|
|
1145
1148
|
);
|
|
1146
1149
|
}
|
|
1147
1150
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync, renameSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
|
|
4
|
+
/** Temp filename Tailwind CLI writes to before the content-hash rename. */
|
|
5
|
+
export const TW_TEMP_BASENAME = ".bosia-tw.build.css";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Content-hash the compiled Tailwind CSS and rename it to its final
|
|
9
|
+
* `bosia-tw-<hash>.css` name (matches staticManifest's HASHED_BASENAME rule,
|
|
10
|
+
* so it gets immutable caching). Returns the final basename.
|
|
11
|
+
*/
|
|
12
|
+
export function finalizeTailwindCss(tempPath: string): string {
|
|
13
|
+
const bytes = readFileSync(tempPath);
|
|
14
|
+
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
|
|
15
|
+
const name = `bosia-tw-${hash}.css`;
|
|
16
|
+
renameSync(tempPath, join(dirname(tempPath), name));
|
|
17
|
+
return name;
|
|
18
|
+
}
|