diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1bf8e99..4713a44 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,8 +1,10 @@
-name: Build and publish
+name: Build, test, and publish nightly
on:
push:
branches: ["main"]
+ pull_request:
+ branches: ["main"]
workflow_dispatch:
jobs:
@@ -15,7 +17,10 @@ jobs:
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- - name: Build
+ - name: Build framework
run: bun run build:deps
+ - name: Run unit tests
+ run: bun run test
+ working-directory: packages/framework
- name: Publish nightly
- run: bunx pkg-pr-new publish './packages/framework' './packages/orpc'
+ run: bunx pkg-pr-new publish './packages/framework'
diff --git a/apps/playground/.gitignore b/apps/playground/.gitignore
index f49e295..8f4a0c7 100644
--- a/apps/playground/.gitignore
+++ b/apps/playground/.gitignore
@@ -4,6 +4,7 @@ node_modules
local.db
.oxide
+.output
# output
out
diff --git a/apps/playground/package.json b/apps/playground/package.json
index 30c966f..9aecfa4 100644
--- a/apps/playground/package.json
+++ b/apps/playground/package.json
@@ -19,7 +19,7 @@
"drizzle-kit": "^0.31.6",
"nitro": "^3.0.1-alpha.1",
"typescript": "^5.9",
- "vite": "^7.1.11"
+ "vite": "7.2.6"
},
"dependencies": {
"@libsql/client": "^0.15.15",
diff --git a/apps/playground/src/app/note.ts b/apps/playground/src/app/note.ts
index f70ecaa..05d8d40 100644
--- a/apps/playground/src/app/note.ts
+++ b/apps/playground/src/app/note.ts
@@ -7,10 +7,11 @@ export const router = {
.input(type<{ name: string; content: string }>())
.handler(async ({ input, context, errors }) => {
if (!context.user) return errors.UNAUTHORIZED();
- const [newNote] = await db.insert(note).values({
+ const result = await db.insert(note).values({
name: input.name,
content: input.content,
userId: context.user?.id,
});
+ return result;
}),
};
diff --git a/apps/playground/src/server.ts b/apps/playground/src/server.ts
index 644511c..30e05f1 100644
--- a/apps/playground/src/server.ts
+++ b/apps/playground/src/server.ts
@@ -35,7 +35,6 @@ export default {
if (orpcResult.matched) {
return orpcResult.response;
}
- console.log(">>>REQ", request);
const authResult = await auth.handler(request);
if (authResult.ok) {
return authResult;
diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts
index c6a5bf8..0f584e1 100644
--- a/apps/playground/vite.config.ts
+++ b/apps/playground/vite.config.ts
@@ -26,9 +26,9 @@ export default defineConfig({
ssr: { build: { rollupOptions: { input: "./src/server.ts" } } },
},
plugins: [
+ oxide(),
tailwindcss(),
svelte({ compilerOptions: { experimental: { async: true } } }),
nitro(),
- oxide(),
],
});
diff --git a/apps/starter/package.json b/apps/starter/package.json
index e1fe1be..8b28420 100644
--- a/apps/starter/package.json
+++ b/apps/starter/package.json
@@ -20,7 +20,7 @@
"svelte": "^5.39.9",
"tailwindcss": "^4.1.14",
"typescript": "^5.9",
- "vite": "^7.1.11"
+ "vite": "7.2.6"
},
"dependencies": {
"@oxidejs/framework": "workspace:*"
diff --git a/bun.lock b/bun.lock
index 8525682..c053c67 100644
--- a/bun.lock
+++ b/bun.lock
@@ -47,7 +47,7 @@
"drizzle-kit": "^0.31.6",
"nitro": "^3.0.1-alpha.1",
"typescript": "^5.9",
- "vite": "^7.1.11",
+ "vite": "7.2.6",
},
},
"apps/starter": {
@@ -67,7 +67,7 @@
"svelte": "^5.39.9",
"tailwindcss": "^4.1.14",
"typescript": "^5.9",
- "vite": "^7.1.11",
+ "vite": "7.2.6",
},
},
"packages/framework": {
@@ -81,7 +81,7 @@
"devDependencies": {
"@types/bun": "latest",
"bunup": "^0.14.20",
- "vite": "7.1.11",
+ "vite": "7.2.6",
},
"peerDependencies": {
"svelte": "^5.39",
@@ -1114,7 +1114,7 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
- "vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
+ "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
@@ -1158,7 +1158,9 @@
"@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
- "@oxidejs/playground/vite": ["vite@7.2.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ=="],
+ "@oxidejs/framework/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
+
+ "@oxidejs/playground/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="],
@@ -1294,6 +1296,10 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
+ "@oxidejs/framework/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
+
+ "@oxidejs/playground/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
+
"unstorage/h3/crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
"@bunup/dts/oxc-minify/@oxc-minify/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
diff --git a/packages/framework/bunup.config.ts b/packages/framework/bunup.config.ts
index 47587d0..5f612c0 100644
--- a/packages/framework/bunup.config.ts
+++ b/packages/framework/bunup.config.ts
@@ -10,4 +10,11 @@ export default defineConfig([
clean: true,
plugins: [copy(["src/components"])],
},
+ {
+ entry: "src/client.ts",
+ format: "esm",
+ target: "browser",
+ outDir: "dist",
+ outFile: "client.js",
+ },
]);
diff --git a/packages/framework/package.json b/packages/framework/package.json
index e155658..58df68a 100644
--- a/packages/framework/package.json
+++ b/packages/framework/package.json
@@ -15,6 +15,10 @@
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
+ "./client": {
+ "import": "./dist/client.js",
+ "types": "./dist/client.d.ts"
+ },
"./virtual": {
"types": "./dist/virtual.d.ts"
},
@@ -26,12 +30,13 @@
"dist"
],
"scripts": {
- "build": "bunup"
+ "build": "bunup",
+ "test": "bun test"
},
"devDependencies": {
"@types/bun": "latest",
"bunup": "^0.14.20",
- "vite": "7.1.11"
+ "vite": "7.2.6"
},
"peerDependencies": {
"svelte": "^5.39",
diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts
new file mode 100644
index 0000000..00eb420
--- /dev/null
+++ b/packages/framework/src/client.ts
@@ -0,0 +1,17 @@
+// Browser-safe exports for client-side use
+// This module contains only what's needed in the browser to avoid Node.js dependencies
+
+export {
+ ROUTER_CONTEXT_KEY,
+ useRouter,
+ useRoute,
+ href,
+ setRouterContext,
+} from "./shared/router-utils.js";
+export type {
+ Location,
+ RouteParams,
+ Router as RouterInterface,
+ Route,
+ RouterContext,
+} from "./shared/router-utils.js";
diff --git a/packages/framework/src/components/router.svelte b/packages/framework/src/components/router.svelte
index 8463204..781f09a 100644
--- a/packages/framework/src/components/router.svelte
+++ b/packages/framework/src/components/router.svelte
@@ -1,5 +1,26 @@
+
+
+
Login
+
+ {#if error}
+
{error}
+ {/if}
+
+
+
+
+
diff --git a/packages/framework/tests/fixtures/app/about.svelte b/packages/framework/tests/fixtures/app/about.svelte
new file mode 100644
index 0000000..d6c06b5
--- /dev/null
+++ b/packages/framework/tests/fixtures/app/about.svelte
@@ -0,0 +1,12 @@
+
+
+
+
About Page
+
This is the about page of our application.
+
diff --git a/packages/framework/tests/fixtures/app/catch-all.svelte b/packages/framework/tests/fixtures/app/catch-all.svelte
new file mode 100644
index 0000000..2d6dacf
--- /dev/null
+++ b/packages/framework/tests/fixtures/app/catch-all.svelte
@@ -0,0 +1,18 @@
+
+
+
+
404 - Page Not Found
+
The page you are looking for could not be found.
+ {#if notFoundPath}
+
Path: /{notFoundPath}
+ {/if}
+
Go Home
+
diff --git a/packages/framework/tests/fixtures/app/index.svelte b/packages/framework/tests/fixtures/app/index.svelte
new file mode 100644
index 0000000..c2bceac
--- /dev/null
+++ b/packages/framework/tests/fixtures/app/index.svelte
@@ -0,0 +1,12 @@
+
+
+
+
Home Page
+
Welcome to the Oxide framework!
+
diff --git a/packages/framework/tests/fixtures/app/users.svelte b/packages/framework/tests/fixtures/app/users.svelte
new file mode 100644
index 0000000..d4a030c
--- /dev/null
+++ b/packages/framework/tests/fixtures/app/users.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/packages/framework/tests/fixtures/app/users/[id].svelte b/packages/framework/tests/fixtures/app/users/[id].svelte
new file mode 100644
index 0000000..5620403
--- /dev/null
+++ b/packages/framework/tests/fixtures/app/users/[id].svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/packages/framework/tests/fixtures/app/users/index.svelte b/packages/framework/tests/fixtures/app/users/index.svelte
new file mode 100644
index 0000000..c96903a
--- /dev/null
+++ b/packages/framework/tests/fixtures/app/users/index.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/packages/framework/tests/generator.test.ts b/packages/framework/tests/generator.test.ts
new file mode 100644
index 0000000..6c2f2d6
--- /dev/null
+++ b/packages/framework/tests/generator.test.ts
@@ -0,0 +1,423 @@
+import { describe, test, expect, beforeEach } from "bun:test";
+import { RouteGenerator } from "../src/generator";
+import type { RouteNode, PluginContext } from "../src/types";
+
+describe("RouteGenerator", () => {
+ let context: PluginContext;
+ let generator: RouteGenerator;
+
+ beforeEach(() => {
+ context = {
+ root: "/test",
+ options: {
+ pagesDir: "src/app",
+ extensions: [".svelte"],
+ importMode: "async",
+ },
+ cache: new Map(),
+ };
+
+ generator = new RouteGenerator(context);
+ });
+
+ function createMockRoute(overrides: Partial = {}): RouteNode {
+ return {
+ name: "test",
+ path: "/test",
+ fullPath: "/test",
+ componentImport: "./test.svelte",
+ children: [],
+ meta: {},
+ params: [],
+ hasComponent: true,
+ filePath: "/test/src/app/test.svelte",
+ ...overrides,
+ };
+ }
+
+ test("generates basic routes structure", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "index",
+ path: "/",
+ componentImport: "./index.svelte",
+ }),
+ createMockRoute({
+ name: "about",
+ path: "/about",
+ componentImport: "./about.svelte",
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain("export const routes = [");
+ expect(result.moduleCode).toContain('name: "index"');
+ expect(result.moduleCode).toContain('name: "about"');
+ expect(result.moduleCode).toContain('path: "/"');
+ expect(result.moduleCode).toContain('path: "/about"');
+ });
+
+ test("generates async imports by default", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "index",
+ path: "/",
+ componentImport: "./index.svelte",
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain('() => import("./index.svelte")');
+ expect(result.moduleCode).not.toContain("import Component_");
+ });
+
+ test("generates sync imports when configured", () => {
+ context.options.importMode = "sync";
+ generator = new RouteGenerator(context);
+
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "index",
+ path: "/",
+ componentImport: "./index.svelte",
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain(
+ 'import Component_0 from "./index.svelte"',
+ );
+ expect(result.moduleCode).toContain("component: Component_0");
+ });
+
+ test("includes route parameters", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "user-id",
+ path: "/users/:id",
+ params: ["id"],
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain('params: ["id"]');
+ });
+
+ test("includes route meta data", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "protected",
+ path: "/protected",
+ meta: { requiresAuth: true, title: "Protected Page" },
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain(
+ 'meta: {"requiresAuth":true,"title":"Protected Page"}',
+ );
+ });
+
+ test("includes route aliases", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "home",
+ path: "/home",
+ alias: ["/", "/index"],
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain('alias: ["/","/index"]');
+ });
+
+ test("handles nested routes with children", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "users",
+ path: "/users",
+ children: [
+ createMockRoute({
+ name: "users-index",
+ path: "/index",
+ componentImport: "./users/index.svelte",
+ }),
+ createMockRoute({
+ name: "users-profile",
+ path: "/profile",
+ componentImport: "./users/profile.svelte",
+ }),
+ ],
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain("children: [");
+ expect(result.moduleCode).toContain('name: "users-index"');
+ expect(result.moduleCode).toContain('name: "users-profile"');
+ });
+
+ test("generates helper functions", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain("function flattenRoutes(routes)");
+ expect(result.moduleCode).toContain(
+ "export function findRouteByName(name)",
+ );
+ expect(result.moduleCode).toContain(
+ "export function generatePath(name, params = {})",
+ );
+ expect(result.moduleCode).toContain("export function matchRoute(pathname)");
+ expect(result.moduleCode).toContain(
+ "export function getRouteParams(pathname, route)",
+ );
+ });
+
+ test("generates virtual module exports", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain(
+ "export { useRouter, useRoute, href, setRouterContext } from '@oxidejs/framework/client';",
+ );
+ });
+
+ test("generates type definitions", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "index",
+ path: "/",
+ }),
+ createMockRoute({
+ name: "user-id",
+ path: "/users/:id",
+ params: ["id"],
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.typeDefinitions).toContain('declare module "$oxide"');
+ expect(result.typeDefinitions).toContain("export interface RouteRecord");
+ expect(result.typeDefinitions).toContain(
+ 'export type RouteNames = "index" | "user-id"',
+ );
+ expect(result.typeDefinitions).toContain("export interface RouteParams");
+ expect(result.typeDefinitions).toContain(
+ '"user-id": {\n id: string;\n }',
+ );
+ expect(result.typeDefinitions).toContain(
+ "export function useRouter(): Router",
+ );
+ expect(result.typeDefinitions).toContain(
+ "export function useRoute(): Route",
+ );
+ expect(result.typeDefinitions).toContain("export function href");
+ });
+
+ test("handles catch-all route parameters in types", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "docs-catch-path",
+ path: "/docs/*",
+ params: ["path"],
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.typeDefinitions).toContain(
+ '"docs-catch-path": {\n path: string;\n }',
+ );
+ });
+
+ test("handles routes without parameters", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "static",
+ path: "/static",
+ params: [],
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.typeDefinitions).toContain('"static": Record');
+ });
+
+ test("skips non-component routes", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [
+ createMockRoute({
+ name: "with-component",
+ hasComponent: true,
+ }),
+ createMockRoute({
+ name: "without-component",
+ hasComponent: false,
+ }),
+ ],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain('name: "with-component"');
+ expect(result.moduleCode).not.toContain('name: "without-component"');
+ });
+
+ test("exports routes as default", () => {
+ const tree: RouteNode = {
+ name: "root",
+ path: "",
+ fullPath: "",
+ componentImport: "",
+ children: [],
+ meta: {},
+ params: [],
+ hasComponent: false,
+ };
+
+ const routes = tree.children?.filter((route) => route.hasComponent) || [];
+ const result = generator.generate(routes);
+
+ expect(result.moduleCode).toContain("export default routes;");
+ });
+});
diff --git a/packages/framework/tests/scanner.test.ts b/packages/framework/tests/scanner.test.ts
new file mode 100644
index 0000000..a694b59
--- /dev/null
+++ b/packages/framework/tests/scanner.test.ts
@@ -0,0 +1,260 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+import { mkdir, writeFile, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { RouteScanner } from "../src/scanner";
+import type { PluginContext } from "../src/types";
+
+describe("RouteScanner", () => {
+ const tempDir = join(process.cwd(), "temp-test-scanner");
+ const appDir = join(tempDir, "src/app");
+
+ let context: PluginContext;
+ let scanner: RouteScanner;
+
+ beforeEach(async () => {
+ await mkdir(appDir, { recursive: true });
+
+ context = {
+ root: tempDir,
+ options: {
+ pagesDir: "src/app",
+ extensions: [".svelte"],
+ },
+ cache: new Map(),
+ };
+
+ scanner = new RouteScanner(context);
+ });
+
+ afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true });
+ });
+
+ async function createFile(path: string, content = "test
") {
+ const fullPath = join(appDir, path);
+ const dir = join(fullPath, "..");
+ await mkdir(dir, { recursive: true });
+ await writeFile(fullPath, content);
+ }
+
+ test("scans basic route files", async () => {
+ await createFile("index.svelte");
+ await createFile("about.svelte");
+
+ const result = await scanner.scan();
+
+ expect(result.files).toHaveLength(2);
+ expect(result.files.some((f) => f.includes("index.svelte"))).toBe(true);
+ expect(result.files.some((f) => f.includes("about.svelte"))).toBe(true);
+ });
+
+ test("builds correct route tree for static routes", async () => {
+ await createFile("index.svelte");
+ await createFile("about.svelte");
+ await createFile("contact.svelte");
+
+ const result = await scanner.scan();
+ const { tree } = result;
+
+ expect(tree.children).toHaveLength(3);
+
+ const indexRoute = tree.children.find((r) => r.name === "index");
+ expect(indexRoute).toBeDefined();
+ expect(indexRoute?.path).toBe("/");
+ expect(indexRoute?.hasComponent).toBe(true);
+
+ const aboutRoute = tree.children.find((r) => r.name === "about");
+ expect(aboutRoute).toBeDefined();
+ expect(aboutRoute?.path).toBe("/about");
+
+ const contactRoute = tree.children.find((r) => r.name === "contact");
+ expect(contactRoute).toBeDefined();
+ expect(contactRoute?.path).toBe("/contact");
+ });
+
+ test("handles dynamic routes with parameters", async () => {
+ await createFile("users/[id].svelte");
+ await createFile("blog/[slug]/comments/[commentId].svelte");
+
+ const result = await scanner.scan();
+ const { tree } = result;
+
+ const userRoute = tree.children.find((r) => r.path === "/users/:id");
+ expect(userRoute).toBeDefined();
+ expect(userRoute?.params).toEqual(["id"]);
+ expect(userRoute?.name).toBe("users-id");
+
+ const commentRoute = tree.children.find(
+ (r) => r.path === "/blog/:slug/comments/:commentId",
+ );
+ expect(commentRoute).toBeDefined();
+ expect(commentRoute?.params).toEqual(["slug", "commentId"]);
+ expect(commentRoute?.name).toBe("blog-slug-comments-commentId");
+ });
+
+ test("handles catch-all routes", async () => {
+ await createFile("docs/[...path].svelte");
+ await createFile("[...notFound].svelte");
+
+ const result = await scanner.scan();
+ const { tree } = result;
+
+ const docsRoute = tree.children.find((r) => r.path === "/docs/*");
+ expect(docsRoute).toBeDefined();
+ expect(docsRoute?.params).toEqual(["path"]);
+ expect(docsRoute?.name).toBe("docs-catch-path");
+
+ const catchAllRoute = tree.children.find((r) => r.path === "/*");
+ expect(catchAllRoute).toBeDefined();
+ expect(catchAllRoute?.params).toEqual(["notFound"]);
+ expect(catchAllRoute?.name).toBe("catch-notFound");
+ });
+
+ test("handles route groups", async () => {
+ await createFile("(auth)/login.svelte");
+ await createFile("(auth)/register.svelte");
+ await createFile("(dashboard)/stats.svelte");
+
+ const result = await scanner.scan();
+ const { tree } = result;
+
+ const loginRoute = tree.children.find((r) => r.path === "/login");
+ expect(loginRoute).toBeDefined();
+ expect(loginRoute?.name).toBe("login");
+
+ const registerRoute = tree.children.find((r) => r.path === "/register");
+ expect(registerRoute).toBeDefined();
+ expect(registerRoute?.name).toBe("register");
+
+ const statsRoute = tree.children.find((r) => r.path === "/stats");
+ expect(statsRoute).toBeDefined();
+ expect(statsRoute?.name).toBe("stats");
+ });
+
+ test("identifies layout files correctly", async () => {
+ // Create layout and its corresponding directory with children
+ await createFile("users.svelte", "{@render children?.()}
");
+ await createFile("users/index.svelte");
+ await createFile("users/profile.svelte");
+
+ const result = await scanner.scan();
+ const { tree } = result;
+
+ const usersLayout = tree.children.find((r) => r.name === "users");
+ expect(usersLayout).toBeDefined();
+ expect(usersLayout?.hasComponent).toBe(true);
+ expect(usersLayout?.children).toHaveLength(2);
+
+ const usersIndex = usersLayout?.children.find(
+ (r) => r.name === "users-index",
+ );
+ expect(usersIndex).toBeDefined();
+ expect(usersIndex?.path).toBe("/index");
+
+ const usersProfile = usersLayout?.children.find(
+ (r) => r.name === "users-profile",
+ );
+ expect(usersProfile).toBeDefined();
+ expect(usersProfile?.path).toBe("/profile");
+ });
+
+ test("handles nested layouts", async () => {
+ await createFile("dashboard.svelte");
+ await createFile("dashboard/settings.svelte");
+ await createFile("dashboard/settings/profile.svelte");
+ await createFile("dashboard/settings/security.svelte");
+
+ const result = await scanner.scan();
+ const { tree } = result;
+
+ const dashboardLayout = tree.children.find((r) => r.name === "dashboard");
+ expect(dashboardLayout?.children).toHaveLength(1);
+
+ const settingsLayout = dashboardLayout?.children.find(
+ (r) => r.name === "dashboard-settings",
+ );
+ expect(settingsLayout?.children).toHaveLength(2);
+ });
+
+ test("ignores invalid file names", async () => {
+ await createFile("valid-file.svelte");
+ await createFile("invalid@file.svelte");
+ await createFile("another$invalid.svelte");
+ await createFile("spaces in name.svelte");
+
+ const result = await scanner.scan();
+
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0]).toContain("valid-file.svelte");
+ });
+
+ test("handles mixed route types correctly", async () => {
+ await createFile("index.svelte");
+ await createFile("about.svelte");
+ await createFile("(auth)/login.svelte");
+ await createFile("users/[id].svelte");
+ await createFile("docs/[...path].svelte");
+ await createFile("api/users.ts"); // Should be ignored (not .svelte)
+
+ const result = await scanner.scan();
+ const { tree } = result;
+
+ expect(tree.children).toHaveLength(5);
+
+ const routes = tree.children.map((r) => ({ name: r.name, path: r.path }));
+ expect(routes).toContainEqual({ name: "index", path: "/" });
+ expect(routes).toContainEqual({ name: "about", path: "/about" });
+ expect(routes).toContainEqual({ name: "login", path: "/login" });
+ expect(routes).toContainEqual({ name: "users-id", path: "/users/:id" });
+ expect(routes).toContainEqual({ name: "docs-catch-path", path: "/docs/*" });
+ });
+
+ test("generates correct component imports", async () => {
+ await createFile("index.svelte");
+ await createFile("users/[id].svelte");
+
+ const result = await scanner.scan();
+ const { tree } = result;
+
+ const indexRoute = tree.children.find((r) => r.name === "index");
+ expect(indexRoute?.componentImport).toContain("/src/app/index.svelte");
+
+ const userRoute = tree.children.find((r) => r.name === "users-id");
+ expect(userRoute?.componentImport).toContain("/src/app/users/[id].svelte");
+ });
+
+ test("applies extendRoute hook", async () => {
+ context.options.extendRoute = async (route) => ({
+ ...route,
+ meta: { ...route.meta, custom: true },
+ });
+
+ scanner = new RouteScanner(context);
+
+ await createFile("index.svelte");
+
+ const result = await scanner.scan();
+ const processedTree = await scanner.applyHooks(result.tree);
+
+ const indexRoute = processedTree.children.find((r) => r.name === "index");
+ expect(indexRoute?.meta.custom).toBe(true);
+ });
+
+ test("handles empty directory", async () => {
+ const result = await scanner.scan();
+
+ expect(result.files).toHaveLength(0);
+ expect(result.tree.children).toHaveLength(0);
+ });
+
+ test("skips hidden directories and files", async () => {
+ await createFile(".hidden/test.svelte");
+ await createFile("node_modules/test.svelte");
+ await createFile("valid.svelte");
+
+ const result = await scanner.scan();
+
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0]).toContain("valid.svelte");
+ });
+});
diff --git a/packages/framework/tests/virtual.test.ts b/packages/framework/tests/virtual.test.ts
new file mode 100644
index 0000000..191d7b8
--- /dev/null
+++ b/packages/framework/tests/virtual.test.ts
@@ -0,0 +1,268 @@
+import { describe, test, expect, beforeEach, mock } from "bun:test";
+
+// Mock Svelte context functions
+const mockGetContext = mock(() => null as any);
+const mockSetContext = mock(() => {});
+
+mock.module("svelte", () => ({
+ getContext: mockGetContext,
+ setContext: mockSetContext,
+}));
+
+describe("Virtual Module", () => {
+ let virtualModule: any;
+
+ beforeEach(async () => {
+ mockGetContext.mockClear();
+ mockSetContext.mockClear();
+
+ // Dynamic import to get fresh module after mocking
+ virtualModule = await import("../src/virtual");
+ });
+
+ describe("useRouter", () => {
+ test("throws error when called outside router context", () => {
+ mockGetContext.mockReturnValue(null);
+
+ expect(() => virtualModule.useRouter()).toThrow(
+ "useRouter() can only be called within a Router component",
+ );
+ });
+
+ test("returns router object with navigation methods", () => {
+ const mockNavigate = mock(() => {});
+ const mockContext = {
+ navigate: mockNavigate,
+ location: () => ({ pathname: "/users/123", search: "", hash: "" }),
+ params: () => ({ id: "123" }),
+ };
+
+ mockGetContext.mockReturnValue(mockContext as any);
+
+ const router = virtualModule.useRouter();
+
+ expect(router).toHaveProperty("push");
+ expect(router).toHaveProperty("replace");
+ expect(router).toHaveProperty("back");
+ expect(router).toHaveProperty("forward");
+
+ // Test push method
+ router.push("/test");
+ expect(mockNavigate).toHaveBeenCalledWith("/test");
+
+ // Test replace method
+ router.replace("/new");
+ expect(mockNavigate).toHaveBeenCalledWith("/new", { replace: true });
+ });
+
+ test("back and forward methods work in browser environment", () => {
+ const mockContext = {
+ navigate: mock(() => {}),
+ location: () => ({ pathname: "/", search: "", hash: "" }),
+ params: () => ({}),
+ };
+
+ mockGetContext.mockReturnValue(mockContext as any);
+
+ const router = virtualModule.useRouter();
+
+ // Test that back and forward methods exist and can be called without error
+ expect(router.back).toBeDefined();
+ expect(router.forward).toBeDefined();
+
+ // These should not throw errors when called
+ expect(() => router.back()).not.toThrow();
+ expect(() => router.forward()).not.toThrow();
+ });
+ });
+
+ describe("useRoute", () => {
+ test("throws error when called outside router context", () => {
+ mockGetContext.mockReturnValue(null);
+
+ expect(() => virtualModule.useRoute()).toThrow(
+ "useRoute() can only be called within a Router component",
+ );
+ });
+
+ test("returns route object with location, params, and query", () => {
+ const mockLocation = {
+ pathname: "/users/123",
+ search: "?tab=profile&sort=name",
+ hash: "#section1",
+ };
+
+ const mockParams = {
+ id: "123",
+ };
+
+ const mockContext = {
+ navigate: mock(() => {}),
+ location: () => mockLocation,
+ params: () => mockParams,
+ };
+
+ mockGetContext.mockReturnValue(mockContext);
+
+ const route = virtualModule.useRoute();
+
+ expect(route.location).toEqual(mockLocation);
+ expect(route.params).toEqual(mockParams);
+ expect(route.query).toBeInstanceOf(URLSearchParams);
+ expect(route.query.get("tab")).toBe("profile");
+ expect(route.query.get("sort")).toBe("name");
+ });
+
+ test("handles empty search params", () => {
+ const mockLocation = {
+ pathname: "/home",
+ search: "",
+ hash: "",
+ };
+
+ const mockContext = {
+ navigate: mock(() => {}),
+ location: () => mockLocation,
+ params: () => ({}),
+ };
+
+ mockGetContext.mockReturnValue(mockContext);
+
+ const route = virtualModule.useRoute();
+
+ expect(route.query.toString()).toBe("");
+ });
+ });
+
+ describe("href", () => {
+ test("constructs basic URLs", () => {
+ const result = virtualModule.href`/users/123`;
+ expect(result).toBe("/users/123");
+ });
+
+ test("interpolates string values with URL encoding", () => {
+ const userId = "user@example.com";
+ const result = virtualModule.href`/users/${userId}`;
+ expect(result).toBe("/users/user%40example.com");
+ });
+
+ test("handles URLSearchParams objects", () => {
+ const params = new URLSearchParams({ q: "search term", page: "2" });
+ const result = virtualModule.href`/search?${params}`;
+ expect(result).toBe("/search?q=search+term&page=2");
+ });
+
+ test("handles array values by joining with slash", () => {
+ const pathSegments = ["docs", "api", "reference"];
+ const result = virtualModule.href`/${pathSegments}/overview`;
+ expect(result).toBe("/docs/api/reference/overview");
+ });
+
+ test("handles multiple interpolations", () => {
+ const category = "electronics";
+ const productId = "123";
+ const tab = "reviews";
+ const result = virtualModule.href`/shop/${category}/products/${productId}?tab=${tab}`;
+ expect(result).toBe("/shop/electronics/products/123?tab=reviews");
+ });
+
+ test("handles complex mixed interpolations", () => {
+ const segments = ["api", "v1"];
+ const resourceId = "test@resource";
+ const params = new URLSearchParams({
+ include: "metadata",
+ format: "json",
+ });
+
+ const result = virtualModule.href`/${segments}/resources/${resourceId}?${params}`;
+ expect(result).toBe(
+ "/api/v1/resources/test%40resource?include=metadata&format=json",
+ );
+ });
+
+ test("handles empty interpolations gracefully", () => {
+ const emptyString = "";
+ const emptyArray: string[] = [];
+ const result = virtualModule.href`/path/${emptyString}/more/${emptyArray}/end`;
+ expect(result).toBe("/path//more//end");
+ });
+
+ test("converts non-string values to strings", () => {
+ const numberId = 42;
+ const boolValue = true;
+ const result = virtualModule.href`/items/${numberId}/active/${boolValue}`;
+ expect(result).toBe("/items/42/active/true");
+ });
+ });
+
+ describe("setRouterContext", () => {
+ test("calls setContext with correct key and value", () => {
+ const mockContext = {
+ navigate: mock(() => {}),
+ location: () => ({ pathname: "/", search: "", hash: "" }),
+ params: () => ({}),
+ };
+
+ virtualModule.setRouterContext(mockContext);
+
+ expect(mockSetContext).toHaveBeenCalledWith(
+ expect.any(Symbol),
+ mockContext,
+ );
+ });
+ });
+
+ describe("TypeScript interfaces", () => {
+ test("Location interface properties", () => {
+ const location: typeof virtualModule.Location = {
+ pathname: "/test",
+ search: "?q=test",
+ hash: "#section",
+ };
+
+ expect(typeof location.pathname).toBe("string");
+ expect(typeof location.search).toBe("string");
+ expect(typeof location.hash).toBe("string");
+ });
+
+ test("RouteParams interface", () => {
+ const params: typeof virtualModule.RouteParams = {
+ id: "123",
+ slug: "test-post",
+ };
+
+ expect(typeof params.id).toBe("string");
+ expect(typeof params.slug).toBe("string");
+ });
+
+ test("Route interface properties", () => {
+ const mockLocation = { pathname: "/", search: "", hash: "" };
+ const mockParams = { id: "123" };
+ const mockQuery = new URLSearchParams();
+
+ const route: typeof virtualModule.Route = {
+ location: mockLocation,
+ params: mockParams,
+ query: mockQuery,
+ };
+
+ expect(route.location).toBe(mockLocation);
+ expect(route.params).toBe(mockParams);
+ expect(route.query).toBe(mockQuery);
+ });
+
+ test("Router interface methods", () => {
+ const router: typeof virtualModule.Router = {
+ push: mock(() => {}),
+ replace: mock(() => {}),
+ back: mock(() => {}),
+ forward: mock(() => {}),
+ };
+
+ expect(typeof router.push).toBe("function");
+ expect(typeof router.replace).toBe("function");
+ expect(typeof router.back).toBe("function");
+ expect(typeof router.forward).toBe("function");
+ });
+ });
+});
diff --git a/packages/framework/tsconfig.json b/packages/framework/tsconfig.json
index ba396eb..f17d140 100644
--- a/packages/framework/tsconfig.json
+++ b/packages/framework/tsconfig.json
@@ -1,3 +1,6 @@
{
- "extends": "@tsconfig/bun/tsconfig.json"
+ "extends": "@tsconfig/bun/tsconfig.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM"]
+ }
}