bosia 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
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/add.ts CHANGED
@@ -27,9 +27,11 @@ interface ComponentMeta {
27
27
  npmDeps: Record<string, string>;
28
28
  }
29
29
 
30
- interface RegistryIndex {
30
+ export interface RegistryIndex {
31
31
  components: string[];
32
32
  features: string[];
33
+ blocks?: string[];
34
+ themes?: string[];
33
35
  }
34
36
 
35
37
  // Track already-installed components within a session to avoid re-running deps
@@ -193,7 +195,7 @@ export function cn(...inputs: ClassValue[]) {
193
195
  }
194
196
  `;
195
197
 
196
- function ensureUtils() {
198
+ export function ensureUtils() {
197
199
  const utilsPath = join(process.cwd(), "src", "lib", "utils.ts");
198
200
  if (!existsSync(utilsPath)) {
199
201
  mkdirSync(dirname(utilsPath), { recursive: true });
@@ -0,0 +1,94 @@
1
+ import { join, dirname } from "path";
2
+ import { mkdirSync, writeFileSync, existsSync } from "fs";
3
+ import * as p from "@clack/prompts";
4
+ import {
5
+ resolveLocalRegistryOrExit,
6
+ readRegistryJSON,
7
+ readRegistryFile,
8
+ bunAdd,
9
+ } from "./registry.ts";
10
+ import { addComponent, initAddRegistry, ensureUtils } from "./add.ts";
11
+ import { mergeFontImports } from "./fonts.ts";
12
+
13
+ // ─── bun x bosia@latest add block <category>/<name> ──────
14
+ // Installs a composed block into src/lib/blocks/<path>/.
15
+ // Recursively installs primitive component dependencies and
16
+ // optional Google Fonts @imports into app.css.
17
+
18
+ interface BlockMeta {
19
+ name: string;
20
+ description: string;
21
+ category: string;
22
+ themes?: string[];
23
+ dependencies: string[]; // primitive component names
24
+ files: string[];
25
+ fonts?: Record<string, string>; // family → @import URL
26
+ npmDeps: Record<string, string>;
27
+ }
28
+
29
+ export async function runAddBlock(name: string | undefined, flags: string[] = []) {
30
+ if (!name || !name.includes("/")) {
31
+ console.error(
32
+ "❌ Please provide a block path.\n Usage: bun x bosia@latest add block <category>/<name> [--local]",
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ const local = flags.includes("--local");
38
+ const registryRoot = local ? resolveLocalRegistryOrExit() : null;
39
+ if (local) console.log(`⬡ Using local registry: ${registryRoot}\n`);
40
+
41
+ await initAddRegistry(registryRoot);
42
+ ensureUtils();
43
+
44
+ console.log(`⬡ Installing block: ${name}\n`);
45
+
46
+ const meta = await readRegistryJSON<BlockMeta>(registryRoot, "blocks", name, "meta.json");
47
+
48
+ // 1. Install primitive dependencies first
49
+ for (const dep of meta.dependencies ?? []) {
50
+ await addComponent(dep, false);
51
+ }
52
+
53
+ // 2. Copy block files to src/lib/blocks/<path>/
54
+ const cwd = process.cwd();
55
+ const destDir = join(cwd, "src", "lib", "blocks", name);
56
+
57
+ if (existsSync(destDir)) {
58
+ const replace = await p.confirm({
59
+ message: `Block "${name}" already exists at src/lib/blocks/${name}/. Replace it?`,
60
+ });
61
+ if (p.isCancel(replace) || !replace) {
62
+ console.log(` ⏭️ Skipped ${name}`);
63
+ return;
64
+ }
65
+ }
66
+
67
+ mkdirSync(destDir, { recursive: true });
68
+
69
+ for (const file of meta.files) {
70
+ const content = await readRegistryFile(registryRoot, "blocks", name, file);
71
+ const dest = join(destDir, file);
72
+ if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
73
+ writeFileSync(dest, content, "utf-8");
74
+ console.log(` ✍️ src/lib/blocks/${name}/${file}`);
75
+ }
76
+
77
+ // 3. Merge font @imports into app.css (idempotent)
78
+ if (meta.fonts && Object.keys(meta.fonts).length > 0) {
79
+ const cssPath = join(cwd, "src", "app.css");
80
+ if (existsSync(cssPath)) {
81
+ const added = mergeFontImports(cssPath, meta.fonts);
82
+ if (added.length > 0) {
83
+ console.log(` 🔤 Added fonts to app.css: ${added.join(", ")}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ // 4. npm deps
89
+ if (meta.npmDeps && Object.keys(meta.npmDeps).length > 0) {
90
+ await bunAdd(cwd, meta.npmDeps);
91
+ }
92
+
93
+ console.log(`\n✅ ${name} installed at src/lib/blocks/${name}/`);
94
+ }
@@ -0,0 +1,61 @@
1
+ import { readFileSync, writeFileSync } from "fs";
2
+
3
+ // ─── Font @import management for app.css ──────────────────
4
+ // Merges Google-Fonts (or any) @import lines into app.css idempotently.
5
+ // Each font URL is bracketed with a marker comment so we can detect and skip
6
+ // duplicates without parsing real CSS.
7
+
8
+ const MARK_PREFIX = "/* bosia-font:";
9
+
10
+ export interface FontEntry {
11
+ [family: string]: string; // family → @import URL
12
+ }
13
+
14
+ /**
15
+ * Inserts `@import url("…");` lines for each font that is not already present.
16
+ * Returns the list of family names that were newly added (empty if no-op).
17
+ */
18
+ export function mergeFontImports(cssPath: string, fonts: FontEntry): string[] {
19
+ const existing = readFileSync(cssPath, "utf-8");
20
+ const added: string[] = [];
21
+ const lines: string[] = [];
22
+
23
+ for (const [family, url] of Object.entries(fonts)) {
24
+ const marker = `${MARK_PREFIX} ${family} */`;
25
+ if (existing.includes(marker)) continue;
26
+ lines.push(`${marker}\n@import url("${url}");`);
27
+ added.push(family);
28
+ }
29
+
30
+ if (lines.length === 0) return [];
31
+
32
+ // Prepend after any opening comment block; simplest: prepend to top.
33
+ const next = lines.join("\n") + "\n" + existing;
34
+ writeFileSync(cssPath, next, "utf-8");
35
+ return added;
36
+ }
37
+
38
+ /**
39
+ * Remove font @imports inserted by Bosia by family name. Used when switching themes.
40
+ */
41
+ export function removeFontImports(cssPath: string, families: string[]): string[] {
42
+ const existing = readFileSync(cssPath, "utf-8");
43
+ let next = existing;
44
+ const removed: string[] = [];
45
+
46
+ for (const family of families) {
47
+ const marker = `${MARK_PREFIX} ${family} */`;
48
+ const re = new RegExp(`${escapeRegExp(marker)}\\n@import url\\("[^"]+"\\);\\n?`, "g");
49
+ if (re.test(next)) {
50
+ next = next.replace(re, "");
51
+ removed.push(family);
52
+ }
53
+ }
54
+
55
+ if (removed.length > 0) writeFileSync(cssPath, next, "utf-8");
56
+ return removed;
57
+ }
58
+
59
+ function escapeRegExp(s: string): string {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ }
package/src/cli/index.ts CHANGED
@@ -37,10 +37,19 @@ async function main() {
37
37
  break;
38
38
  }
39
39
  case "add": {
40
- const { runAdd } = await import("./add.ts");
41
- const addName = args.find((a) => !a.startsWith("--"));
42
- const addFlags = args.filter((a) => a.startsWith("--"));
43
- await runAdd(addName, addFlags);
40
+ const positional = args.filter((a) => !a.startsWith("--"));
41
+ const flags = args.filter((a) => a.startsWith("--"));
42
+ const sub = positional[0];
43
+ if (sub === "block") {
44
+ const { runAddBlock } = await import("./block.ts");
45
+ await runAddBlock(positional[1], flags);
46
+ } else if (sub === "theme") {
47
+ const { runAddTheme } = await import("./theme.ts");
48
+ await runAddTheme(positional[1], flags);
49
+ } else {
50
+ const { runAdd } = await import("./add.ts");
51
+ await runAdd(sub, flags);
52
+ }
44
53
  break;
45
54
  }
46
55
  case "feat": {
@@ -63,8 +72,10 @@ Commands:
63
72
  build Build for production
64
73
  start Run the production server
65
74
  test [args] Run tests with bun test (auto-loads .env.test, sets BOSIA_ENV=test)
66
- add <component> Add a UI component from the registry
67
- feat <feature> Add a feature scaffold from the registry [--local]
75
+ add <component> Add a UI component from the registry
76
+ add block <cat>/<name> Add a composed block from the registry
77
+ add theme <name> Add a theme (tokens.css) from the registry
78
+ feat <feature> Add a feature scaffold from the registry [--local]
68
79
 
69
80
  Examples:
70
81
  bun x bosia@latest create my-app
@@ -77,6 +88,8 @@ Examples:
77
88
  bun x bosia test --coverage
78
89
  bun x bosia@latest add button → src/lib/components/ui/button/
79
90
  bun x bosia@latest add shop/cart → src/lib/components/shop/cart/
91
+ bun x bosia@latest add block cards/feature-editorial
92
+ bun x bosia@latest add theme editorial
80
93
  bun x bosia@latest feat login
81
94
  `);
82
95
  break;
@@ -0,0 +1,88 @@
1
+ import { join } from "path";
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
+ import { resolveLocalRegistryOrExit, readRegistryJSON, readRegistryFile } from "./registry.ts";
4
+ import { mergeFontImports } from "./fonts.ts";
5
+
6
+ // ─── bun x bosia@latest add theme <name> ─────────────────
7
+ // Installs a theme tokens.css to src/lib/themes/<name>.css and
8
+ // rewrites the active theme @import in src/app.css. One theme
9
+ // active at a time (v1 assumption).
10
+
11
+ interface ThemeMeta {
12
+ name: string;
13
+ description: string;
14
+ files: string[];
15
+ fonts?: Record<string, string>;
16
+ npmDeps?: Record<string, string>;
17
+ }
18
+
19
+ const THEME_IMPORT_RE = /^@import\s+["']\.\/lib\/themes\/[^"']+["'];?\s*$/m;
20
+ const THEME_MARKER = "/* bosia-theme */";
21
+
22
+ export async function runAddTheme(name: string | undefined, flags: string[] = []) {
23
+ if (!name) {
24
+ console.error(
25
+ "❌ Please provide a theme name.\n Usage: bun x bosia@latest add theme <name> [--local]",
26
+ );
27
+ process.exit(1);
28
+ }
29
+
30
+ const local = flags.includes("--local");
31
+ const registryRoot = local ? resolveLocalRegistryOrExit() : null;
32
+ if (local) console.log(`⬡ Using local registry: ${registryRoot}\n`);
33
+
34
+ console.log(`⬡ Installing theme: ${name}\n`);
35
+
36
+ const meta = await readRegistryJSON<ThemeMeta>(registryRoot, "themes", name, "meta.json");
37
+
38
+ const cwd = process.cwd();
39
+ const themesDir = join(cwd, "src", "lib", "themes");
40
+ mkdirSync(themesDir, { recursive: true });
41
+
42
+ // Copy tokens.css (and any other files) to src/lib/themes/<name>.css
43
+ // Convention: first file is the tokens file, written as <name>.css.
44
+ const tokensFile = meta.files[0] ?? "tokens.css";
45
+ const content = await readRegistryFile(registryRoot, "themes", name, tokensFile);
46
+ const tokensDest = join(themesDir, `${name}.css`);
47
+ writeFileSync(tokensDest, content, "utf-8");
48
+ console.log(` ✍️ src/lib/themes/${name}.css`);
49
+
50
+ // Patch app.css: swap any existing ./lib/themes/*.css import for this one
51
+ const appCssPath = join(cwd, "src", "app.css");
52
+ if (existsSync(appCssPath)) {
53
+ patchAppCssThemeImport(appCssPath, name);
54
+ console.log(` 🎨 app.css → @import "./lib/themes/${name}.css"`);
55
+ } else {
56
+ console.warn(` ⚠️ src/app.css not found — theme import not wired automatically.`);
57
+ }
58
+
59
+ // Font @imports
60
+ if (meta.fonts && Object.keys(meta.fonts).length > 0 && existsSync(appCssPath)) {
61
+ const added = mergeFontImports(appCssPath, meta.fonts);
62
+ if (added.length > 0) console.log(` 🔤 Added fonts: ${added.join(", ")}`);
63
+ }
64
+
65
+ console.log(`\n✅ ${name} theme installed.`);
66
+ }
67
+
68
+ function patchAppCssThemeImport(appCssPath: string, themeName: string) {
69
+ const src = readFileSync(appCssPath, "utf-8");
70
+ const newImport = `${THEME_MARKER}\n@import "./lib/themes/${themeName}.css";`;
71
+
72
+ let next: string;
73
+ if (THEME_IMPORT_RE.test(src)) {
74
+ next = src.replace(THEME_IMPORT_RE, `@import "./lib/themes/${themeName}.css";`);
75
+ if (!next.includes(THEME_MARKER)) {
76
+ next = next.replace(`@import "./lib/themes/${themeName}.css";`, newImport);
77
+ }
78
+ } else {
79
+ // Insert after the tailwindcss @import line so @theme {} is processed correctly.
80
+ const tw = /^(@import\s+["']tailwindcss["'];\s*\n)/m;
81
+ if (tw.test(src)) {
82
+ next = src.replace(tw, `$1${newImport}\n`);
83
+ } else {
84
+ next = `${newImport}\n${src}`;
85
+ }
86
+ }
87
+ writeFileSync(appCssPath, next, "utf-8");
88
+ }
@@ -3,7 +3,8 @@
3
3
  import { findMatch } from "../matcher.ts";
4
4
  import { clientRoutes } from "bosia:routes";
5
5
  import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
6
- import { appState } from "./appState.svelte.ts";
6
+ import { appState, clearDirty } from "./appState.svelte.ts";
7
+ import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
7
8
  import { pickErrorPage } from "../errorMatch.ts";
8
9
 
9
10
  let {
@@ -49,6 +50,10 @@
49
50
  $effect(() => {
50
51
  if (ssrMode) return;
51
52
 
53
+ // Subscribe to `invalidationTick` so `invalidate()` can wake the effect
54
+ // without a URL change.
55
+ void appState.invalidationTick;
56
+
52
57
  const path = router.currentRoute;
53
58
  const pathname = path.split("?")[0].split("#")[0];
54
59
  const match = findMatch(clientRoutes, pathname);
@@ -68,6 +73,43 @@
68
73
  navDone = false;
69
74
  navigating = true;
70
75
 
76
+ // ─── Loader cache: decide which loaders need to re-run ─────────────
77
+ // For each layout depth + the page, compare the cached entry (if any)
78
+ // against the live URL/params and the dirty set. If everything is
79
+ // cacheable, skip the fetch entirely.
80
+ const url = new URL(path, window.location.origin);
81
+ const ctx = liveContext(pathname, match.params, url);
82
+ const layoutIds = (match.route as any).layoutIds as (string | null)[];
83
+ const pageId = (match.route as any).pageId as string | null;
84
+
85
+ const layoutRunFlags: boolean[] = layoutIds.map((id) => {
86
+ if (id === null) return false; // no server loader at this depth
87
+ const entry = appState.loaderCache.layouts[id];
88
+ if (!entry) return true; // never loaded → must run
89
+ return shouldRerun(entry, appState.dirty, ctx);
90
+ });
91
+
92
+ let pageRun = false;
93
+ if (pageId !== null) {
94
+ const entry = appState.loaderCache.page;
95
+ if (!entry || entry.nodeId !== pageId) {
96
+ pageRun = true;
97
+ } else {
98
+ pageRun = shouldRerun(entry, appState.dirty, ctx);
99
+ }
100
+ }
101
+
102
+ // Build `_invalidated=<bits>` — char 0 = page, char i+1 = layouts[i].
103
+ // '1' = run, '0' = skip. Always sent so the server honors cached layers.
104
+ // We always issue the fetch (even when all loaders skip) so page-level
105
+ // metadata stays fresh on every navigation; only the loaders flagged in
106
+ // the mask actually run server-side.
107
+ const maskBits =
108
+ (pageRun ? "1" : "0") + layoutRunFlags.map((b) => (b ? "1" : "0")).join("");
109
+
110
+ // Clear dirty set now — we've baked it into the mask.
111
+ clearDirty();
112
+
71
113
  // Load components + data in parallel, then update state atomically
72
114
  // to avoid a flash of stale/empty data before the fetch completes.
73
115
  const cached = match.route.hasServerData ? consumePrefetch(path) : null;
@@ -75,7 +117,7 @@
75
117
  const dataFetch = cached
76
118
  ? Promise.resolve(cached)
77
119
  : match.route.hasServerData
78
- ? fetch(dataUrl(path))
120
+ ? fetch(dataUrl(path, maskBits))
79
121
  .then((r) => r.json())
80
122
  .catch(() => null)
81
123
  : Promise.resolve(null);
@@ -143,9 +185,83 @@
143
185
  }
144
186
  PageComponent = pageMod.default;
145
187
  layoutComponents = layoutMods.map((m: any) => m.default);
146
- appState.pageData = result?.pageData ?? {};
147
- appState.layoutData = result?.layoutData ?? [];
148
- appState.routeParams = result?.pageData?.params ?? match.params;
188
+
189
+ // Merge sparse server response with the existing client cache.
190
+ // Slots the server returned null for were intentionally skipped — we
191
+ // must pull their data from the cache to keep rendering correct.
192
+ const respLayoutData: (Record<string, any> | null)[] = result?.layoutData ?? [];
193
+ const respLayoutDeps: (any | null)[] = result?.layoutDeps ?? [];
194
+ const respPageData: Record<string, any> | null | undefined = result?.pageData;
195
+ const respPageDeps: any = result?.pageDeps ?? null;
196
+
197
+ const mergedLayoutData: Record<string, any>[] = [];
198
+ for (let i = 0; i < layoutIds.length; i++) {
199
+ const id = layoutIds[i];
200
+ if (id === null) {
201
+ mergedLayoutData.push({});
202
+ continue;
203
+ }
204
+ if (respLayoutData[i] !== null && respLayoutData[i] !== undefined) {
205
+ const entry: CacheEntry = {
206
+ nodeId: id,
207
+ data: respLayoutData[i] as Record<string, any>,
208
+ deps: respLayoutDeps[i] ?? {
209
+ keys: [],
210
+ urls: [],
211
+ params: [],
212
+ searchParams: [],
213
+ cookies: [],
214
+ uses_url: false,
215
+ },
216
+ snapshot: captureSnapshot(
217
+ respLayoutDeps[i] ?? {
218
+ keys: [],
219
+ urls: [],
220
+ params: [],
221
+ searchParams: [],
222
+ cookies: [],
223
+ uses_url: false,
224
+ },
225
+ ctx,
226
+ ),
227
+ };
228
+ appState.loaderCache.layouts[id] = entry;
229
+ mergedLayoutData.push(entry.data);
230
+ } else {
231
+ const cachedEntry = appState.loaderCache.layouts[id];
232
+ mergedLayoutData.push(cachedEntry?.data ?? {});
233
+ }
234
+ }
235
+
236
+ let mergedPageData: Record<string, any>;
237
+ if (respPageData !== null && respPageData !== undefined) {
238
+ const deps = respPageDeps ?? {
239
+ keys: [],
240
+ urls: [],
241
+ params: [],
242
+ searchParams: [],
243
+ cookies: [],
244
+ uses_url: false,
245
+ };
246
+ const entry: CacheEntry = {
247
+ nodeId: pageId ?? "",
248
+ data: respPageData,
249
+ deps,
250
+ snapshot: captureSnapshot(deps, ctx),
251
+ };
252
+ if (pageId !== null) appState.loaderCache.page = entry;
253
+ mergedPageData = respPageData;
254
+ } else if (appState.loaderCache.page && appState.loaderCache.page.nodeId === pageId) {
255
+ mergedPageData = appState.loaderCache.page.data;
256
+ } else {
257
+ mergedPageData = {};
258
+ }
259
+
260
+ // Always overlay current match.params — cached pageData carries the
261
+ // stale params from when the loader ran, so trust the live match.
262
+ appState.pageData = { ...mergedPageData, params: match.params };
263
+ appState.layoutData = mergedLayoutData;
264
+ appState.routeParams = match.params;
149
265
  // Successful navigation — clear any prior error state.
150
266
  appState.errorComponent = null;
151
267
  appState.errorProps = null;
@@ -7,8 +7,7 @@
7
7
  // `ssrMode` and reads from `ssrXxx` props directly during SSR,
8
8
  // so concurrent requests don't share these cells.
9
9
 
10
- import { dataUrl } from "./prefetch.ts";
11
- import { router } from "./router.svelte.ts";
10
+ import type { CacheEntry, DirtyState } from "./loaderCache.ts";
12
11
 
13
12
  class AppState {
14
13
  pageData = $state<Record<string, any>>({});
@@ -21,43 +20,31 @@ class AppState {
21
20
  errorComponent = $state<any>(null);
22
21
  errorProps = $state<{ error: { status: number; message: string } } | null>(null);
23
22
  errorDepth = $state<number | null>(null);
23
+ // Loader cache — keyed by stable id (codegen-emitted server file path).
24
+ // Mirrors the most recent successful run of each layout/page server loader.
25
+ // Wiped on hard refresh, never persisted.
26
+ loaderCache: { page: CacheEntry | null; layouts: Record<string, CacheEntry> } = {
27
+ page: null,
28
+ layouts: {},
29
+ };
30
+ // Pending invalidations consumed by the next data fetch. Mutated by
31
+ // `invalidate()` / `invalidateAll()` and cleared after the request fires.
32
+ dirty: DirtyState = {
33
+ all: false,
34
+ keys: new Set<string>(),
35
+ urls: new Set<string>(),
36
+ urlMatchers: [],
37
+ };
38
+ // Bumped by `invalidate*()` to wake the App.svelte nav effect, so calling
39
+ // `invalidate("k")` re-runs the loader pipeline without changing the URL.
40
+ invalidationTick = $state(0);
24
41
  }
25
42
 
26
43
  export const appState = new AppState();
27
44
 
28
- /**
29
- * Re-fetch loader data for the given path and apply to `appState`.
30
- * Used by `use:enhance` after a successful action — mirrors SvelteKit's
31
- * `invalidateAll` default. No-op if the fetch fails or returns an error.
32
- */
33
- export async function refreshData(path: string): Promise<void> {
34
- try {
35
- const res = await fetch(dataUrl(path));
36
- if (!res.ok) return;
37
- const result = await res.json();
38
- if (result?.redirect) {
39
- router.navigate(result.redirect);
40
- return;
41
- }
42
- if (result?.error) return;
43
- appState.pageData = result?.pageData ?? {};
44
- appState.layoutData = result?.layoutData ?? [];
45
- appState.routeParams = result?.pageData?.params ?? appState.routeParams;
46
- if (result?.metadata) {
47
- if (result.metadata.title) document.title = result.metadata.title;
48
- if (result.metadata.description) {
49
- let meta = document.querySelector(
50
- 'meta[name="description"]',
51
- ) as HTMLMetaElement | null;
52
- if (!meta) {
53
- meta = document.createElement("meta");
54
- meta.name = "description";
55
- document.head.appendChild(meta);
56
- }
57
- meta.content = result.metadata.description;
58
- }
59
- }
60
- } catch {
61
- // best-effort — silently swallow
62
- }
45
+ export function clearDirty(): void {
46
+ appState.dirty.all = false;
47
+ appState.dirty.keys.clear();
48
+ appState.dirty.urls.clear();
49
+ appState.dirty.urlMatchers = [];
63
50
  }
@@ -5,7 +5,7 @@
5
5
  // full page reload. Falls back to native form submission when JS is
6
6
  // disabled because nothing is wired up until this action runs.
7
7
 
8
- import { appState, refreshData } from "./appState.svelte.ts";
8
+ import { appState } from "./appState.svelte.ts";
9
9
  import { router } from "./router.svelte.ts";
10
10
 
11
11
  export type ActionResult =
@@ -53,7 +53,11 @@ async function applyResult(
53
53
  appState.form = result.data;
54
54
  if (reset) form.reset();
55
55
  if (invalidateAll) {
56
- await refreshData(window.location.pathname + window.location.search);
56
+ // Form actions invalidate the page loader only by default — layouts
57
+ // stay cached. Loaders that need to react to a mutation should call
58
+ // `invalidate("app:key")` from the action's submit handler.
59
+ appState.loaderCache.page = null;
60
+ appState.invalidationTick = appState.invalidationTick + 1;
57
61
  }
58
62
  }
59
63
 
@@ -5,10 +5,22 @@ import { initPrefetch } from "./prefetch.ts";
5
5
  import { findMatch, compileRoutes, canonicalPathname } from "../matcher.ts";
6
6
  import { clientRoutes } from "bosia:routes";
7
7
  import { appState } from "./appState.svelte.ts";
8
+ import { captureSnapshot, liveContext, type CacheEntry } from "./loaderCache.ts";
9
+ import type { LoaderDeps } from "../hooks.ts";
8
10
 
9
11
  // Pre-compile route patterns into RegExp at startup (shared by App.svelte and router via module reference)
10
12
  compileRoutes(clientRoutes);
11
13
 
14
+ function readJsonScript<T>(id: string): T | null {
15
+ const el = document.getElementById(id);
16
+ if (!el) return null;
17
+ try {
18
+ return JSON.parse(el.textContent ?? "null") as T;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
12
24
  // ─── Hydration ────────────────────────────────────────────
13
25
 
14
26
  async function main() {
@@ -49,9 +61,9 @@ async function main() {
49
61
  router.params = match.params;
50
62
  }
51
63
 
52
- const ssrPageData = (window as any).__BOSIA_PAGE_DATA__ ?? {};
53
- const ssrLayoutData = (window as any).__BOSIA_LAYOUT_DATA__ ?? [];
54
- const ssrFormData = (window as any).__BOSIA_FORM_DATA__ ?? null;
64
+ const ssrPageData = readJsonScript<Record<string, any>>("__bosia-page-data__") ?? {};
65
+ const ssrLayoutData = readJsonScript<Record<string, any>[]>("__bosia-layout-data__") ?? [];
66
+ const ssrFormData = readJsonScript<Record<string, any>>("__bosia-form-data__");
55
67
 
56
68
  // Seed shared client state so `use:enhance` and other helpers
57
69
  // start from the same values App.svelte renders during hydration.
@@ -60,6 +72,42 @@ async function main() {
60
72
  appState.routeParams = ssrPageData?.params ?? match?.params ?? {};
61
73
  appState.form = ssrFormData;
62
74
 
75
+ // Seed the loader cache from window globals emitted server-side so the
76
+ // next client navigation can decide which loaders to skip without an
77
+ // extra fetch round-trip.
78
+ if (match) {
79
+ const url = new URL(window.location.href);
80
+ const ctx = liveContext(window.location.pathname, match.params, url);
81
+ const ssrPageDeps: LoaderDeps | null = (window as any).__BOSIA_PAGE_DEPS__ ?? null;
82
+ const ssrLayoutDeps: (LoaderDeps | null)[] = (window as any).__BOSIA_LAYOUT_DEPS__ ?? [];
83
+ const pageId = (match.route as any).pageId as string | null;
84
+ const layoutIds = (match.route as any).layoutIds as (string | null)[];
85
+
86
+ if (pageId !== null && ssrPageDeps && ssrPageData) {
87
+ const entry: CacheEntry = {
88
+ nodeId: pageId,
89
+ data: ssrPageData,
90
+ deps: ssrPageDeps,
91
+ snapshot: captureSnapshot(ssrPageDeps, ctx),
92
+ };
93
+ appState.loaderCache.page = entry;
94
+ }
95
+ for (let i = 0; i < layoutIds.length; i++) {
96
+ const id = layoutIds[i];
97
+ if (id === null) continue;
98
+ const deps = ssrLayoutDeps[i];
99
+ const data = ssrLayoutData[i];
100
+ if (!deps || !data) continue;
101
+ const entry: CacheEntry = {
102
+ nodeId: id,
103
+ data,
104
+ deps,
105
+ snapshot: captureSnapshot(deps, ctx),
106
+ };
107
+ appState.loaderCache.layouts[id] = entry;
108
+ }
109
+ }
110
+
63
111
  const target = document.getElementById("app")!;
64
112
  const props = {
65
113
  ssrMode: false,