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 +1 -1
- package/src/cli/add.ts +4 -2
- package/src/cli/block.ts +94 -0
- package/src/cli/fonts.ts +61 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/theme.ts +88 -0
- package/src/core/client/App.svelte +121 -5
- package/src/core/client/appState.svelte.ts +24 -37
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +51 -3
- package/src/core/client/loaderCache.ts +127 -0
- package/src/core/client/navigation.ts +59 -0
- package/src/core/client/prefetch.ts +48 -3
- package/src/core/cors.ts +57 -11
- package/src/core/csp.ts +47 -0
- package/src/core/csrf.ts +8 -5
- package/src/core/dev.ts +14 -2
- package/src/core/errors.ts +4 -3
- package/src/core/hooks.ts +37 -1
- package/src/core/html.ts +68 -26
- package/src/core/prerender.ts +11 -0
- package/src/core/renderer.ts +346 -35
- package/src/core/routeFile.ts +26 -0
- package/src/core/safePath.ts +14 -0
- package/src/core/server.ts +103 -15
- package/src/lib/client.ts +1 -0
- package/src/lib/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.
|
|
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 });
|
package/src/cli/block.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/fonts.ts
ADDED
|
@@ -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
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
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>
|
|
67
|
-
|
|
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;
|
package/src/cli/theme.ts
ADDED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
53
|
-
const ssrLayoutData =
|
|
54
|
-
const ssrFormData =
|
|
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,
|