bosia 0.5.9 → 0.5.11
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/font.ts +45 -0
- package/src/cli/index.ts +5 -0
- package/src/core/client/App.svelte +23 -3
- package/src/core/client/appState.svelte.ts +4 -0
- package/src/core/client/hydrate.ts +5 -1
- package/src/core/client/navListeners.ts +51 -0
- package/src/core/client/navigation.ts +99 -0
- package/src/core/client/router.svelte.ts +69 -5
- package/src/core/dev-error-page.ts +78 -0
- package/src/core/devErrorReport.ts +2 -2
- package/src/core/html.ts +1 -1
- package/src/core/renderer.ts +6 -0
- package/src/lib/client.ts +8 -1
- package/templates/default/public/logo-dark.svg +14 -0
- package/templates/default/public/logo-light.svg +14 -0
- package/templates/demo/public/logo-dark.svg +14 -0
- package/templates/demo/public/logo-light.svg +14 -0
- package/templates/todo/public/logo-dark.svg +14 -0
- package/templates/todo/public/logo-light.svg +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
|
|
6
6
|
"keywords": [
|
package/src/cli/font.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { mergeFontImports } from "./fonts.ts";
|
|
4
|
+
|
|
5
|
+
// ─── bun x bosia@latest add font <family> <url> ─────────────
|
|
6
|
+
// Prepends a Google-Fonts-style `@import url(...)` to src/app.css
|
|
7
|
+
// with a `/* bosia-font: <family> */` marker so it's idempotent
|
|
8
|
+
// and survives Tailwind v4 / LightningCSS @import-ordering rules.
|
|
9
|
+
//
|
|
10
|
+
// Why a dedicated command: appending the @import at the bottom
|
|
11
|
+
// (or anywhere after `@import "tailwindcss"`) causes LightningCSS
|
|
12
|
+
// to silently drop the rule from public/bosia-tw.css. Hand-edits
|
|
13
|
+
// frequently get this wrong; this command always prepends.
|
|
14
|
+
|
|
15
|
+
export async function runAddFont(family: string | undefined, url: string | undefined) {
|
|
16
|
+
if (!family || !url) {
|
|
17
|
+
console.error(
|
|
18
|
+
"❌ Please provide a font family and URL.\n" +
|
|
19
|
+
' Usage: bun x bosia@latest add font "<Family>" "<@import url>"\n' +
|
|
20
|
+
' Example: bun x bosia@latest add font "Fredoka" \\\n' +
|
|
21
|
+
' "https://fonts.googleapis.com/css2?family=Fredoka:wght@400;700&display=swap"',
|
|
22
|
+
);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cwd = process.cwd();
|
|
27
|
+
const appCssPath = join(cwd, "src", "app.css");
|
|
28
|
+
|
|
29
|
+
if (!existsSync(appCssPath)) {
|
|
30
|
+
console.error(`❌ src/app.css not found at ${appCssPath}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const added = mergeFontImports(appCssPath, { [family]: url });
|
|
35
|
+
|
|
36
|
+
if (added.length === 0) {
|
|
37
|
+
console.log(`⬡ Font already present: ${family} (no changes)`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`✅ Added font: ${family}`);
|
|
42
|
+
console.log(` ✍️ src/app.css ← @import url("${url}");`);
|
|
43
|
+
console.log("\n💡 Next: declare it on a token in src/app.css, e.g.");
|
|
44
|
+
console.log(' @theme { --font-display: "' + family + '", sans-serif; }');
|
|
45
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -48,6 +48,9 @@ async function main() {
|
|
|
48
48
|
const themeFlags = args.filter((a) => a.startsWith("--"));
|
|
49
49
|
const { runAddTheme } = await import("./theme.ts");
|
|
50
50
|
await runAddTheme(positional[1], themeFlags);
|
|
51
|
+
} else if (sub === "font") {
|
|
52
|
+
const { runAddFont } = await import("./font.ts");
|
|
53
|
+
await runAddFont(positional[1], positional[2]);
|
|
51
54
|
} else {
|
|
52
55
|
const { runAdd } = await import("./add.ts");
|
|
53
56
|
await runAdd(positional, flags);
|
|
@@ -77,6 +80,7 @@ Commands:
|
|
|
77
80
|
add <component...> [-y] Add one or more UI components from the registry
|
|
78
81
|
add block <cat>/<name> Add a composed block from the registry
|
|
79
82
|
add theme <name> Add a theme (tokens.css) from the registry
|
|
83
|
+
add font <family> <url> Prepend an @import url(...) for a font family to src/app.css
|
|
80
84
|
feat <feature> Add a feature scaffold from the registry [--local]
|
|
81
85
|
|
|
82
86
|
Examples:
|
|
@@ -94,6 +98,7 @@ Examples:
|
|
|
94
98
|
bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
|
|
95
99
|
bun x bosia@latest add block cards/feature-editorial
|
|
96
100
|
bun x bosia@latest add theme editorial
|
|
101
|
+
bun x bosia@latest add font "Fredoka" "https://fonts.googleapis.com/css2?family=Fredoka:wght@400;700&display=swap"
|
|
97
102
|
bun x bosia@latest feat login
|
|
98
103
|
`);
|
|
99
104
|
break;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
7
7
|
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
8
8
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
9
|
+
import { fireAfterNavigate, type Navigation } from "./navListeners.ts";
|
|
10
|
+
import { drainNavResolvers } from "./navigation.ts";
|
|
9
11
|
|
|
10
12
|
let {
|
|
11
13
|
ssrMode = false,
|
|
@@ -129,6 +131,18 @@
|
|
|
129
131
|
.catch(() => null)
|
|
130
132
|
: Promise.resolve(null);
|
|
131
133
|
|
|
134
|
+
const settle = (target: { url: URL; params: Record<string, string> } | null) => {
|
|
135
|
+
const nav: Navigation = {
|
|
136
|
+
from: null,
|
|
137
|
+
to: target,
|
|
138
|
+
type: router.lastNavType,
|
|
139
|
+
willUnload: false,
|
|
140
|
+
cancel: () => {},
|
|
141
|
+
};
|
|
142
|
+
fireAfterNavigate(nav);
|
|
143
|
+
drainNavResolvers();
|
|
144
|
+
};
|
|
145
|
+
|
|
132
146
|
Promise.all([
|
|
133
147
|
match.route.page(),
|
|
134
148
|
Promise.all(match.route.layouts.map((l: any) => l())),
|
|
@@ -184,7 +198,9 @@
|
|
|
184
198
|
appState.errorComponent = errMod.default;
|
|
185
199
|
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
186
200
|
appState.errorDepth = K;
|
|
187
|
-
if (router.isPush) window.scrollTo(0, 0);
|
|
201
|
+
if (router.isPush && !router.suppressScroll) window.scrollTo(0, 0);
|
|
202
|
+
router.suppressScroll = false;
|
|
203
|
+
settle({ url, params: match.params });
|
|
188
204
|
} catch {
|
|
189
205
|
window.location.href = path;
|
|
190
206
|
}
|
|
@@ -272,8 +288,10 @@
|
|
|
272
288
|
appState.errorProps = null;
|
|
273
289
|
appState.errorDepth = null;
|
|
274
290
|
|
|
275
|
-
// Scroll to top on forward navigation (not on popstate/back-forward)
|
|
276
|
-
|
|
291
|
+
// Scroll to top on forward navigation (not on popstate/back-forward).
|
|
292
|
+
// goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
|
|
293
|
+
if (router.isPush && !router.suppressScroll) window.scrollTo(0, 0);
|
|
294
|
+
router.suppressScroll = false;
|
|
277
295
|
|
|
278
296
|
// Update document title and meta description from server metadata
|
|
279
297
|
if (result?.metadata) {
|
|
@@ -290,6 +308,8 @@
|
|
|
290
308
|
meta.content = result.metadata.description;
|
|
291
309
|
}
|
|
292
310
|
}
|
|
311
|
+
|
|
312
|
+
settle({ url, params: match.params });
|
|
293
313
|
});
|
|
294
314
|
|
|
295
315
|
return () => {
|
|
@@ -38,6 +38,10 @@ class AppState {
|
|
|
38
38
|
// Bumped by `invalidate*()` to wake the App.svelte nav effect, so calling
|
|
39
39
|
// `invalidate("k")` re-runs the loader pipeline without changing the URL.
|
|
40
40
|
invalidationTick = $state(0);
|
|
41
|
+
// Resolvers waiting for the next navigation to settle. Drained by
|
|
42
|
+
// App.svelte after each nav effect finishes (success or error). Used by
|
|
43
|
+
// `goto()` to return a Promise that mirrors SvelteKit's contract.
|
|
44
|
+
navResolvers: Array<() => void> = [];
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
export const appState = new AppState();
|
|
@@ -130,7 +130,11 @@ async function main() {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
main()
|
|
133
|
+
main().catch((err) => {
|
|
134
|
+
// Without this, a hydration failure leaves "Loading..." stuck on screen
|
|
135
|
+
// with no console signal. Surface it loudly instead.
|
|
136
|
+
console.error("[bosia] hydration failed", err);
|
|
137
|
+
});
|
|
134
138
|
|
|
135
139
|
// ─── Hot Reload (dev only) ────────────────────────────────
|
|
136
140
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// ─── Navigation lifecycle registry ────────────────────────
|
|
2
|
+
// Shared listener storage for `beforeNavigate` / `afterNavigate`. Kept in a
|
|
3
|
+
// dedicated module so `router.svelte.ts` can fire events without importing
|
|
4
|
+
// `navigation.ts` (which transitively imports the router — would deadlock
|
|
5
|
+
// the ESM evaluation cycle).
|
|
6
|
+
|
|
7
|
+
export interface NavigationTarget {
|
|
8
|
+
url: URL;
|
|
9
|
+
params: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Navigation {
|
|
13
|
+
from: NavigationTarget | null;
|
|
14
|
+
to: NavigationTarget | null;
|
|
15
|
+
type: "link" | "goto" | "popstate" | "form" | "enter";
|
|
16
|
+
willUnload: boolean;
|
|
17
|
+
cancel: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type BeforeNavigateCallback = (nav: Navigation) => void | Promise<void>;
|
|
21
|
+
export type AfterNavigateCallback = (nav: Navigation) => void;
|
|
22
|
+
|
|
23
|
+
export const beforeListeners = new Set<BeforeNavigateCallback>();
|
|
24
|
+
export const afterListeners = new Set<AfterNavigateCallback>();
|
|
25
|
+
|
|
26
|
+
/** Returns true when navigation should proceed, false when a listener cancelled it. */
|
|
27
|
+
export function fireBeforeNavigate(nav: Navigation): boolean {
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
nav.cancel = () => {
|
|
30
|
+
cancelled = true;
|
|
31
|
+
};
|
|
32
|
+
for (const fn of beforeListeners) {
|
|
33
|
+
try {
|
|
34
|
+
fn(nav);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.warn("[bosia] beforeNavigate listener threw", err);
|
|
37
|
+
}
|
|
38
|
+
if (cancelled) return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function fireAfterNavigate(nav: Navigation): void {
|
|
44
|
+
for (const fn of afterListeners) {
|
|
45
|
+
try {
|
|
46
|
+
fn(nav);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn("[bosia] afterNavigate listener threw", err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -10,10 +10,47 @@
|
|
|
10
10
|
// await invalidate((url) => url.pathname.startsWith("/api/"));
|
|
11
11
|
// await invalidateAll();
|
|
12
12
|
|
|
13
|
+
import { onDestroy } from "svelte";
|
|
13
14
|
import { appState } from "./appState.svelte.ts";
|
|
15
|
+
import { router } from "./router.svelte.ts";
|
|
16
|
+
import {
|
|
17
|
+
afterListeners,
|
|
18
|
+
beforeListeners,
|
|
19
|
+
type AfterNavigateCallback,
|
|
20
|
+
type BeforeNavigateCallback,
|
|
21
|
+
} from "./navListeners.ts";
|
|
14
22
|
|
|
15
23
|
type InvalidateTarget = string | URL | ((url: URL) => boolean);
|
|
16
24
|
|
|
25
|
+
export type { Navigation, NavigationTarget } from "./navListeners.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a callback that runs before each client-side navigation. The
|
|
29
|
+
* callback may call `nav.cancel()` to block the navigation. Auto-unregisters
|
|
30
|
+
* when the calling Svelte component is destroyed.
|
|
31
|
+
*/
|
|
32
|
+
export function beforeNavigate(fn: BeforeNavigateCallback): void {
|
|
33
|
+
beforeListeners.add(fn);
|
|
34
|
+
try {
|
|
35
|
+
onDestroy(() => beforeListeners.delete(fn));
|
|
36
|
+
} catch {
|
|
37
|
+
// Not inside a component — caller is responsible for lifetime.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register a callback that runs after each client-side navigation settles.
|
|
43
|
+
* Auto-unregisters when the calling Svelte component is destroyed.
|
|
44
|
+
*/
|
|
45
|
+
export function afterNavigate(fn: AfterNavigateCallback): void {
|
|
46
|
+
afterListeners.add(fn);
|
|
47
|
+
try {
|
|
48
|
+
onDestroy(() => afterListeners.delete(fn));
|
|
49
|
+
} catch {
|
|
50
|
+
// Not inside a component — caller is responsible for lifetime.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
17
54
|
function bumpTick() {
|
|
18
55
|
appState.invalidationTick = appState.invalidationTick + 1;
|
|
19
56
|
}
|
|
@@ -57,3 +94,65 @@ export function invalidateAll(): Promise<void> {
|
|
|
57
94
|
bumpTick();
|
|
58
95
|
return Promise.resolve();
|
|
59
96
|
}
|
|
97
|
+
|
|
98
|
+
// ─── goto ─────────────────────────────────────────────────
|
|
99
|
+
// Programmatic SPA navigation. Counterpart to SvelteKit's `goto()`.
|
|
100
|
+
//
|
|
101
|
+
// Usage:
|
|
102
|
+
// import { goto } from "bosia/client";
|
|
103
|
+
// await goto("/dashboard");
|
|
104
|
+
// await goto("/login", { replaceState: true, invalidateAll: true });
|
|
105
|
+
|
|
106
|
+
export interface GotoOptions {
|
|
107
|
+
/** Use `history.replaceState` instead of `history.pushState`. */
|
|
108
|
+
replaceState?: boolean;
|
|
109
|
+
/** Mark every loader dirty so the next nav re-runs all of them. */
|
|
110
|
+
invalidateAll?: boolean;
|
|
111
|
+
/** Skip the default scroll-to-top after navigation. */
|
|
112
|
+
noScroll?: boolean;
|
|
113
|
+
/** Reserved — not yet honored by the framework. */
|
|
114
|
+
keepFocus?: boolean;
|
|
115
|
+
/** Reserved — not yet honored (no shallow routing). */
|
|
116
|
+
state?: Record<string, unknown>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Navigate to `url` via the client router. Returns a Promise that resolves
|
|
121
|
+
* after the navigation has settled (loaders ran, components mounted) or
|
|
122
|
+
* immediately if the URL matches the current route.
|
|
123
|
+
*/
|
|
124
|
+
export function goto(url: string, opts: GotoOptions = {}): Promise<void> {
|
|
125
|
+
if (typeof window === "undefined") return Promise.resolve();
|
|
126
|
+
|
|
127
|
+
if (opts.invalidateAll) {
|
|
128
|
+
appState.dirty.all = true;
|
|
129
|
+
}
|
|
130
|
+
if (opts.noScroll) {
|
|
131
|
+
router.suppressScroll = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const beforePath = router.currentRoute;
|
|
135
|
+
|
|
136
|
+
return new Promise<void>((resolve) => {
|
|
137
|
+
appState.navResolvers.push(resolve);
|
|
138
|
+
router.navigate(url, { replace: opts.replaceState, source: "goto" });
|
|
139
|
+
// `navigate()` short-circuits when `currentRoute === path` — the nav
|
|
140
|
+
// effect won't fire, so nothing will drain the resolver. Resolve now.
|
|
141
|
+
if (router.currentRoute === beforePath) {
|
|
142
|
+
drainNavResolvers();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Internal — App.svelte calls this after each nav effect settles. */
|
|
148
|
+
export function drainNavResolvers(): void {
|
|
149
|
+
const queue = appState.navResolvers;
|
|
150
|
+
appState.navResolvers = [];
|
|
151
|
+
for (const fn of queue) {
|
|
152
|
+
try {
|
|
153
|
+
fn();
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.warn("[bosia] navResolver threw", err);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
import { findMatch, canonicalPathname } from "../matcher.ts";
|
|
6
6
|
import { clientRoutes } from "bosia:routes";
|
|
7
|
+
import { fireBeforeNavigate, type Navigation } from "./navListeners.ts";
|
|
8
|
+
|
|
9
|
+
export type NavType = "link" | "goto" | "popstate" | "form" | "enter";
|
|
10
|
+
|
|
11
|
+
function buildTarget(path: string): { url: URL; params: Record<string, string> } | null {
|
|
12
|
+
if (typeof window === "undefined") return null;
|
|
13
|
+
const pathname = path.split("?")[0].split("#")[0];
|
|
14
|
+
const match = findMatch(clientRoutes, pathname);
|
|
15
|
+
const url = new URL(path, window.location.origin);
|
|
16
|
+
return { url, params: match?.params ?? {} };
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
export const router = new (class Router {
|
|
9
20
|
currentRoute = $state(
|
|
@@ -14,8 +25,12 @@ export const router = new (class Router {
|
|
|
14
25
|
params = $state<Record<string, string>>({});
|
|
15
26
|
/** True when navigation was triggered by a link click / navigate() call, false on popstate (back/forward). */
|
|
16
27
|
isPush = $state(true);
|
|
28
|
+
/** Source of the most recent navigation — feeds the Navigation object passed to lifecycle hooks. */
|
|
29
|
+
lastNavType: NavType = "enter";
|
|
30
|
+
/** Set by `goto({ noScroll: true })`; consumed once by App.svelte after the next nav settles. */
|
|
31
|
+
suppressScroll = false;
|
|
17
32
|
|
|
18
|
-
navigate(path: string) {
|
|
33
|
+
navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}) {
|
|
19
34
|
if (this.currentRoute === path) return;
|
|
20
35
|
// Unknown route — let the server handle it (renders +error.svelte with 404)
|
|
21
36
|
const queryHash = path.slice(path.split("?")[0].split("#")[0].length);
|
|
@@ -31,10 +46,28 @@ export const router = new (class Router {
|
|
|
31
46
|
(match.route as any).trailingSlash ?? "never",
|
|
32
47
|
);
|
|
33
48
|
const finalPath = canonical !== null ? canonical + queryHash : path;
|
|
49
|
+
|
|
50
|
+
const navType: NavType = opts.source ?? "link";
|
|
51
|
+
const fromTarget = buildTarget(this.currentRoute);
|
|
52
|
+
const toTarget = buildTarget(finalPath);
|
|
53
|
+
const nav: Navigation = {
|
|
54
|
+
from: fromTarget,
|
|
55
|
+
to: toTarget,
|
|
56
|
+
type: navType,
|
|
57
|
+
willUnload: false,
|
|
58
|
+
cancel: () => {},
|
|
59
|
+
};
|
|
60
|
+
if (!fireBeforeNavigate(nav)) return;
|
|
61
|
+
|
|
62
|
+
this.lastNavType = navType;
|
|
34
63
|
this.isPush = true;
|
|
35
64
|
this.currentRoute = finalPath;
|
|
36
65
|
if (typeof history !== "undefined") {
|
|
37
|
-
|
|
66
|
+
if (opts.replace) {
|
|
67
|
+
history.replaceState({}, "", finalPath);
|
|
68
|
+
} else {
|
|
69
|
+
history.pushState({}, "", finalPath);
|
|
70
|
+
}
|
|
38
71
|
}
|
|
39
72
|
}
|
|
40
73
|
|
|
@@ -57,14 +90,45 @@ export const router = new (class Router {
|
|
|
57
90
|
if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
|
|
58
91
|
|
|
59
92
|
e.preventDefault();
|
|
60
|
-
this.navigate(anchor.pathname + anchor.search + anchor.hash);
|
|
93
|
+
this.navigate(anchor.pathname + anchor.search + anchor.hash, { source: "link" });
|
|
61
94
|
});
|
|
62
95
|
|
|
63
96
|
// Browser back/forward
|
|
64
97
|
window.addEventListener("popstate", () => {
|
|
65
|
-
|
|
66
|
-
this.currentRoute =
|
|
98
|
+
const finalPath =
|
|
67
99
|
window.location.pathname + window.location.search + window.location.hash;
|
|
100
|
+
// Fire beforeNavigate listeners; popstate can't be reliably cancelled
|
|
101
|
+
// (browser history already advanced), so we surface the event for
|
|
102
|
+
// observation only — `cancel()` is a no-op for this source.
|
|
103
|
+
const fromTarget = buildTarget(this.currentRoute);
|
|
104
|
+
const toTarget = buildTarget(finalPath);
|
|
105
|
+
const nav: Navigation = {
|
|
106
|
+
from: fromTarget,
|
|
107
|
+
to: toTarget,
|
|
108
|
+
type: "popstate",
|
|
109
|
+
willUnload: false,
|
|
110
|
+
cancel: () => {},
|
|
111
|
+
};
|
|
112
|
+
fireBeforeNavigate(nav);
|
|
113
|
+
|
|
114
|
+
this.lastNavType = "popstate";
|
|
115
|
+
this.isPush = false;
|
|
116
|
+
this.currentRoute = finalPath;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Full-page unload — fire beforeNavigate with willUnload=true so
|
|
120
|
+
// listeners can warn-on-leave (return value ignored; cancellation here
|
|
121
|
+
// requires `beforeunload`, not in scope).
|
|
122
|
+
window.addEventListener("beforeunload", () => {
|
|
123
|
+
const fromTarget = buildTarget(this.currentRoute);
|
|
124
|
+
const nav: Navigation = {
|
|
125
|
+
from: fromTarget,
|
|
126
|
+
to: null,
|
|
127
|
+
type: "link",
|
|
128
|
+
willUnload: true,
|
|
129
|
+
cancel: () => {},
|
|
130
|
+
};
|
|
131
|
+
fireBeforeNavigate(nav);
|
|
68
132
|
});
|
|
69
133
|
}
|
|
70
134
|
})();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { safeJsonForScript } from "./html.ts";
|
|
2
|
+
import { getOverlayScript } from "./plugins/inspector/overlay.ts";
|
|
3
|
+
|
|
4
|
+
export interface DevServerError {
|
|
5
|
+
id: string;
|
|
6
|
+
ts: number;
|
|
7
|
+
source: string;
|
|
8
|
+
message: string;
|
|
9
|
+
stack?: string;
|
|
10
|
+
file?: string;
|
|
11
|
+
line?: number;
|
|
12
|
+
col?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal HTML shown when the dev proxy can't reach the app server (initial
|
|
17
|
+
* build failure, crash loop, port conflict). Mounts the same inspector overlay
|
|
18
|
+
* so the red error badge appears identical to the in-app experience, pre-seeds
|
|
19
|
+
* the buffered errors that fired before the page loaded, and subscribes to the
|
|
20
|
+
* dev SSE channel so it auto-reloads when the next build succeeds.
|
|
21
|
+
*/
|
|
22
|
+
export function renderDevErrorPage(buffered: DevServerError[]): string {
|
|
23
|
+
const overlay = getOverlayScript({ endpoint: "/__bosia/locate", errorsEnabled: true });
|
|
24
|
+
const seed = safeJsonForScript(buffered);
|
|
25
|
+
|
|
26
|
+
return `<!DOCTYPE html>
|
|
27
|
+
<html lang="en">
|
|
28
|
+
<head>
|
|
29
|
+
<meta charset="UTF-8">
|
|
30
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
31
|
+
<title>Dev server error — Bosia</title>
|
|
32
|
+
<style>
|
|
33
|
+
html,body{margin:0;padding:0;height:100%;background:#0a0a0a;color:#e5e5e5;font:14px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif}
|
|
34
|
+
.wrap{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}
|
|
35
|
+
.card{max-width:540px;text-align:center}
|
|
36
|
+
.dot{display:inline-block;width:10px;height:10px;background:#dc2626;border-radius:50%;margin-right:8px;vertical-align:middle;animation:p 1.4s ease-in-out infinite}
|
|
37
|
+
@keyframes p{0%,100%{opacity:1}50%{opacity:.3}}
|
|
38
|
+
h1{font-size:18px;font-weight:600;margin:0 0 8px}
|
|
39
|
+
p{margin:0;color:#a3a3a3;font-size:13px}
|
|
40
|
+
code{font-family:ui-monospace,monospace;color:#fafafa;background:#1f1f1f;padding:1px 6px;border-radius:3px}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<div class="wrap">
|
|
45
|
+
<div class="card">
|
|
46
|
+
<h1><span class="dot"></span>Dev server error</h1>
|
|
47
|
+
<p>See the red badge in the bottom-right for details. This page will reload automatically when the next build succeeds.</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<script type="application/json" id="__bosia-dev-errors__">${seed}</script>
|
|
51
|
+
${overlay}
|
|
52
|
+
<script>
|
|
53
|
+
(function(){
|
|
54
|
+
function seed(){
|
|
55
|
+
var node=document.getElementById("__bosia-dev-errors__");
|
|
56
|
+
if(!node||!window.__BOSIA_PUSH_ERROR__)return false;
|
|
57
|
+
try{
|
|
58
|
+
var list=JSON.parse(node.textContent||"[]");
|
|
59
|
+
for(var i=0;i<list.length;i++)window.__BOSIA_PUSH_ERROR__(list[i]);
|
|
60
|
+
}catch(_){}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if(!seed()){
|
|
64
|
+
var tries=0;
|
|
65
|
+
var iv=setInterval(function(){tries++;if(seed()||tries>20)clearInterval(iv)},50);
|
|
66
|
+
}
|
|
67
|
+
!function r(){
|
|
68
|
+
try{
|
|
69
|
+
var e=new EventSource("/__bosia/sse");
|
|
70
|
+
e.addEventListener("reload",function(){location.reload()});
|
|
71
|
+
e.onerror=function(){e.close();setTimeout(r,2000)};
|
|
72
|
+
}catch(_){setTimeout(r,2000)}
|
|
73
|
+
}();
|
|
74
|
+
})();
|
|
75
|
+
</script>
|
|
76
|
+
</body>
|
|
77
|
+
</html>`;
|
|
78
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// is a safe no-op otherwise.
|
|
7
7
|
|
|
8
8
|
interface DevReportInput {
|
|
9
|
-
source?: "
|
|
9
|
+
source?: "server" | "uncaught" | "rejection";
|
|
10
10
|
message: string;
|
|
11
11
|
stack?: string;
|
|
12
12
|
}
|
|
@@ -22,7 +22,7 @@ export function reportDevErrorFromCatch(err: unknown): void {
|
|
|
22
22
|
const e = err as Error | undefined;
|
|
23
23
|
try {
|
|
24
24
|
fn({
|
|
25
|
-
source: "
|
|
25
|
+
source: "server",
|
|
26
26
|
message: e?.message ?? String(err),
|
|
27
27
|
stack: e?.stack,
|
|
28
28
|
});
|
package/src/core/html.ts
CHANGED
|
@@ -160,7 +160,7 @@ export function buildHtml(
|
|
|
160
160
|
` <script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
|
|
161
161
|
` ${fallbackTitle}${head}` +
|
|
162
162
|
headCloseInterpolated +
|
|
163
|
-
`\n${SPINNER}` +
|
|
163
|
+
(body ? "" : `\n${SPINNER}`) +
|
|
164
164
|
`\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
|
|
165
165
|
tailInterpolated
|
|
166
166
|
);
|
package/src/core/renderer.ts
CHANGED
|
@@ -42,6 +42,7 @@ async function pluginRenderFragments(
|
|
|
42
42
|
} catch (err) {
|
|
43
43
|
if (isDev) console.error(`Plugin "${p.name}" render.${hook} failed:`, err);
|
|
44
44
|
else console.error(`Plugin "${p.name}" render.${hook} failed:`, (err as Error).message);
|
|
45
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
return out;
|
|
@@ -481,6 +482,7 @@ export async function loadMetadata(
|
|
|
481
482
|
} catch (err) {
|
|
482
483
|
if (isDev) console.error("Metadata load error:", err);
|
|
483
484
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
485
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
484
486
|
}
|
|
485
487
|
return null;
|
|
486
488
|
}
|
|
@@ -524,6 +526,7 @@ export async function renderSSRStream(
|
|
|
524
526
|
}
|
|
525
527
|
if (isDev) console.error("Metadata load error:", err);
|
|
526
528
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
529
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
527
530
|
// Continue with null metadata — don't break the page for a metadata failure
|
|
528
531
|
}
|
|
529
532
|
|
|
@@ -651,6 +654,7 @@ export async function renderSSRStream(
|
|
|
651
654
|
} catch (err) {
|
|
652
655
|
if (isDev) console.error("SSR render error:", err);
|
|
653
656
|
else console.error("SSR render error:", (err as Error).message ?? err);
|
|
657
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
654
658
|
// Render-phase errors fall through to deepest boundary like a page error.
|
|
655
659
|
return renderErrorPage(
|
|
656
660
|
500,
|
|
@@ -910,6 +914,7 @@ export async function renderErrorPage(
|
|
|
910
914
|
"Nested error page render failed:",
|
|
911
915
|
(err as Error).message ?? err,
|
|
912
916
|
);
|
|
917
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
913
918
|
// fall through to global / text fallback
|
|
914
919
|
}
|
|
915
920
|
}
|
|
@@ -944,6 +949,7 @@ export async function renderErrorPage(
|
|
|
944
949
|
} catch (err) {
|
|
945
950
|
if (isDev) console.error("Error page render failed:", err);
|
|
946
951
|
else console.error("Error page render failed:", (err as Error).message ?? err);
|
|
952
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
947
953
|
}
|
|
948
954
|
}
|
|
949
955
|
return new Response(message, {
|
package/src/lib/client.ts
CHANGED
|
@@ -10,4 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
export { enhance } from "../core/client/enhance.ts";
|
|
12
12
|
export type { SubmitFunction, ActionResult } from "../core/client/enhance.ts";
|
|
13
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
afterNavigate,
|
|
15
|
+
beforeNavigate,
|
|
16
|
+
goto,
|
|
17
|
+
invalidate,
|
|
18
|
+
invalidateAll,
|
|
19
|
+
} from "../core/client/navigation.ts";
|
|
20
|
+
export type { GotoOptions, Navigation, NavigationTarget } from "../core/client/navigation.ts";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<!-- Top block -->
|
|
3
|
+
<rect fill="#f0f0f0" x="50" y="50" width="28" height="28" rx="6"/>
|
|
4
|
+
<rect fill="#f0f0f0" x="86" y="50" width="60" height="28" rx="6"/>
|
|
5
|
+
|
|
6
|
+
<!-- Middle block -->
|
|
7
|
+
<rect fill="#f0f0f0" x="86" y="86" width="72" height="28" rx="6"/>
|
|
8
|
+
|
|
9
|
+
<!-- Bottom block -->
|
|
10
|
+
<rect fill="#f0f0f0" x="86" y="122" width="60" height="28" rx="6"/>
|
|
11
|
+
|
|
12
|
+
<!-- Connector bar on left -->
|
|
13
|
+
<rect fill="#f0f0f0" x="50" y="50" width="28" height="100" rx="6"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<!-- Top block -->
|
|
3
|
+
<rect fill="#1a1a1a" x="50" y="50" width="28" height="28" rx="6"/>
|
|
4
|
+
<rect fill="#1a1a1a" x="86" y="50" width="60" height="28" rx="6"/>
|
|
5
|
+
|
|
6
|
+
<!-- Middle block -->
|
|
7
|
+
<rect fill="#1a1a1a" x="86" y="86" width="72" height="28" rx="6"/>
|
|
8
|
+
|
|
9
|
+
<!-- Bottom block -->
|
|
10
|
+
<rect fill="#1a1a1a" x="86" y="122" width="60" height="28" rx="6"/>
|
|
11
|
+
|
|
12
|
+
<!-- Connector bar on left -->
|
|
13
|
+
<rect fill="#1a1a1a" x="50" y="50" width="28" height="100" rx="6"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<!-- Top block -->
|
|
3
|
+
<rect fill="#f0f0f0" x="50" y="50" width="28" height="28" rx="6"/>
|
|
4
|
+
<rect fill="#f0f0f0" x="86" y="50" width="60" height="28" rx="6"/>
|
|
5
|
+
|
|
6
|
+
<!-- Middle block -->
|
|
7
|
+
<rect fill="#f0f0f0" x="86" y="86" width="72" height="28" rx="6"/>
|
|
8
|
+
|
|
9
|
+
<!-- Bottom block -->
|
|
10
|
+
<rect fill="#f0f0f0" x="86" y="122" width="60" height="28" rx="6"/>
|
|
11
|
+
|
|
12
|
+
<!-- Connector bar on left -->
|
|
13
|
+
<rect fill="#f0f0f0" x="50" y="50" width="28" height="100" rx="6"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<!-- Top block -->
|
|
3
|
+
<rect fill="#1a1a1a" x="50" y="50" width="28" height="28" rx="6"/>
|
|
4
|
+
<rect fill="#1a1a1a" x="86" y="50" width="60" height="28" rx="6"/>
|
|
5
|
+
|
|
6
|
+
<!-- Middle block -->
|
|
7
|
+
<rect fill="#1a1a1a" x="86" y="86" width="72" height="28" rx="6"/>
|
|
8
|
+
|
|
9
|
+
<!-- Bottom block -->
|
|
10
|
+
<rect fill="#1a1a1a" x="86" y="122" width="60" height="28" rx="6"/>
|
|
11
|
+
|
|
12
|
+
<!-- Connector bar on left -->
|
|
13
|
+
<rect fill="#1a1a1a" x="50" y="50" width="28" height="100" rx="6"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<!-- Top block -->
|
|
3
|
+
<rect fill="#f0f0f0" x="50" y="50" width="28" height="28" rx="6"/>
|
|
4
|
+
<rect fill="#f0f0f0" x="86" y="50" width="60" height="28" rx="6"/>
|
|
5
|
+
|
|
6
|
+
<!-- Middle block -->
|
|
7
|
+
<rect fill="#f0f0f0" x="86" y="86" width="72" height="28" rx="6"/>
|
|
8
|
+
|
|
9
|
+
<!-- Bottom block -->
|
|
10
|
+
<rect fill="#f0f0f0" x="86" y="122" width="60" height="28" rx="6"/>
|
|
11
|
+
|
|
12
|
+
<!-- Connector bar on left -->
|
|
13
|
+
<rect fill="#f0f0f0" x="50" y="50" width="28" height="100" rx="6"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<!-- Top block -->
|
|
3
|
+
<rect fill="#1a1a1a" x="50" y="50" width="28" height="28" rx="6"/>
|
|
4
|
+
<rect fill="#1a1a1a" x="86" y="50" width="60" height="28" rx="6"/>
|
|
5
|
+
|
|
6
|
+
<!-- Middle block -->
|
|
7
|
+
<rect fill="#1a1a1a" x="86" y="86" width="72" height="28" rx="6"/>
|
|
8
|
+
|
|
9
|
+
<!-- Bottom block -->
|
|
10
|
+
<rect fill="#1a1a1a" x="86" y="122" width="60" height="28" rx="6"/>
|
|
11
|
+
|
|
12
|
+
<!-- Connector bar on left -->
|
|
13
|
+
<rect fill="#1a1a1a" x="50" y="50" width="28" height="100" rx="6"/>
|
|
14
|
+
</svg>
|