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} + +
+
+ + +
+ +
+ + +
+ + +
+ +
+

Don't have an account? Sign up

+
+
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 @@ + + +
+

{user.name}

+

ID: {userId}

+

Email: {user.email}

+ ← Back to Users +
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 @@ + + +
+

All Users

+
+
+

John Doe

+ View Profile +
+
+

Jane Smith

+ View Profile +
+
+
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"] + } }