diff --git a/.changeset/export-build-file-routes.md b/.changeset/export-build-file-routes.md new file mode 100644 index 0000000..a422334 --- /dev/null +++ b/.changeset/export-build-file-routes.md @@ -0,0 +1,12 @@ +--- +"@geajs/core": patch +"@geajs/vite-plugin": patch +--- + +### @geajs/core (patch) + +- **Export buildFileRoutes**: `buildFileRoutes` is now exported from the public `@geajs/core` entry point, making it available for the `router.setPath()` build-time transform and for any user who needs to integrate file-based routing manually. + +### @geajs/vite-plugin (patch) + +- **router.setPath() transform**: Include `buildFileRoutes` export and `router.setPath()` integration — the plugin now rewrites `router.setPath('./pages')` calls into the expanded `router.setRoutes(buildFileRoutes(...))` form at build time. diff --git a/examples/router-file-based/README.md b/examples/router-file-based/README.md new file mode 100644 index 0000000..0203c8d --- /dev/null +++ b/examples/router-file-based/README.md @@ -0,0 +1,28 @@ +# router-file-based + +Demonstrates **file-based routing** in Gea using `router.setPath('./pages')`. + +## Pages structure + +``` +src/pages/ + layout.tsx # root layout — nav bar + + page.tsx # / + about/page.tsx # /about + blog/page.tsx # /blog + blog/[slug]/page.tsx # /blog/:slug (dynamic) + users/page.tsx # /users + users/[id]/page.tsx # /users/:id (dynamic) + [...all]/page.tsx # * (catch-all 404) +``` + +## How it works + +`main.ts` calls `router.setPath('./pages')` once. The `@geajs/vite-plugin` transforms this at +build time into `import.meta.glob` calls — layouts are loaded eagerly, pages are lazy-loaded. + +## Run + +```bash +npx vite dev --port 5188 +``` diff --git a/examples/router-file-based/index.html b/examples/router-file-based/index.html new file mode 100644 index 0000000..55b3eac --- /dev/null +++ b/examples/router-file-based/index.html @@ -0,0 +1,18 @@ + + + + + File-Based Router - Gea + + + + + + +
+ + + diff --git a/examples/router-file-based/package.json b/examples/router-file-based/package.json new file mode 100644 index 0000000..f67e7d2 --- /dev/null +++ b/examples/router-file-based/package.json @@ -0,0 +1,18 @@ +{ + "name": "router-file-based-gea", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@geajs/core": "file:../../packages/gea" + }, + "devDependencies": { + "vite": "^8.0.0", + "@geajs/vite-plugin": "file:../../packages/vite-plugin-gea", + "typescript": "~5.8.0" + } +} diff --git a/examples/router-file-based/src/App.tsx b/examples/router-file-based/src/App.tsx new file mode 100644 index 0000000..98c37c4 --- /dev/null +++ b/examples/router-file-based/src/App.tsx @@ -0,0 +1,17 @@ +import { Component } from '@geajs/core' +import { router, RouterView } from '@geajs/core' + +export default class App extends Component { + template() { + if (router.error) { + return ( +
+

Something went wrong

+

{router.error}

+ +
+ ) + } + return + } +} diff --git a/examples/router-file-based/src/main.ts b/examples/router-file-based/src/main.ts new file mode 100644 index 0000000..a1f49a1 --- /dev/null +++ b/examples/router-file-based/src/main.ts @@ -0,0 +1,11 @@ +import { router } from '@geajs/core' +import App from './App' +import './styles.css' + +router.setPath('./pages') + +const root = document.getElementById('app') +if (!root) throw new Error('App root element not found') + +const app = new App() +app.render(root) diff --git a/examples/router-file-based/src/pages/[...all]/page.tsx b/examples/router-file-based/src/pages/[...all]/page.tsx new file mode 100644 index 0000000..45e89e5 --- /dev/null +++ b/examples/router-file-based/src/pages/[...all]/page.tsx @@ -0,0 +1,16 @@ +import { Component } from '@geajs/core' +import { router, Link } from '@geajs/core' + +export default class NotFoundPage extends Component { + template() { + return ( +
+

404

+

+ No route matched {router.path}. +

+ +
+ ) + } +} diff --git a/examples/router-file-based/src/pages/about/page.tsx b/examples/router-file-based/src/pages/about/page.tsx new file mode 100644 index 0000000..86a32c8 --- /dev/null +++ b/examples/router-file-based/src/pages/about/page.tsx @@ -0,0 +1,42 @@ +import { Component } from '@geajs/core' + +const SETUP_CODE = 'router.setPath(\'./pages\')' + +export default class AboutPage extends Component { + template() { + return ( +
+

About

+

+ This example demonstrates file-based routing in Gea. Instead of defining a route map + by hand, call {SETUP_CODE} once in your entry file and the Vite plugin + generates routes automatically from the file system. +

+

File conventions

+
    +
  • page.tsx — page component for the route
  • +
  • layout.tsx — wraps all routes in the directory
  • +
  • [param]/page.tsx — dynamic segment, becomes :param
  • +
  • [...slug]/page.tsx — catch-all segment, becomes *
  • +
+

How it works

+

+ At build time the Vite plugin rewrites the setPath() call into + import.meta.glob statements. Layouts load eagerly; pages load lazily. +

+

This example's page structure

+
{
+`pages/
+  layout.tsx            root layout (nav + Outlet)
+  page.tsx              /
+  about/page.tsx        /about
+  blog/page.tsx         /blog
+  blog/[slug]/page.tsx  /blog/:slug
+  users/page.tsx        /users
+  users/[id]/page.tsx   /users/:id
+  [...all]/page.tsx     * (404 catch-all)`
+        }
+
+ ) + } +} diff --git a/examples/router-file-based/src/pages/blog/[slug]/page.tsx b/examples/router-file-based/src/pages/blog/[slug]/page.tsx new file mode 100644 index 0000000..e9a449f --- /dev/null +++ b/examples/router-file-based/src/pages/blog/[slug]/page.tsx @@ -0,0 +1,66 @@ +import { Component } from '@geajs/core' +import { Link } from '@geajs/core' + +interface Post { + slug: string + title: string + date: string + content: string +} + +const POSTS: Record = { + 'getting-started': { + slug: 'getting-started', + title: 'Getting Started with Gea', + date: '2026-03-01', + content: `Gea is a lightweight reactive framework that compiles your component templates at build time. +To get started, create a new project with the CLI: + + npm create gea@latest my-app + +Then run the dev server and start building your app.`, + }, + 'file-based-routing': { + slug: 'file-based-routing', + title: 'File-Based Routing in Gea', + date: '2026-03-10', + content: `File-based routing lets you define routes by creating files in a pages directory. +Call router.setPath('./pages') once in your entry file and the Vite plugin does the rest. + +The plugin transforms this call into import.meta.glob statements at build time, so you +get lazy-loaded pages and eager-loaded layouts with zero configuration.`, + }, + 'reactive-stores': { + slug: 'reactive-stores', + title: 'Reactive Stores', + date: '2026-03-18', + content: `Gea's reactivity system tracks property access at the field level using a Proxy-based Store class. +When a component reads a reactive field, it subscribes automatically. When the field changes, +only the components that read it are re-rendered — no virtual DOM diffing required.`, + }, +} + +export default class BlogPostPage extends Component { + template({ slug }: { slug: string }) { + const post = POSTS[slug] + + if (!post) { + return ( +
+

Post not found

+

No post with slug {slug}.

+ +
+ ) + } + + return ( +
+ + +

{post.title}

+
{post.content}
+
+ ) + } +} diff --git a/examples/router-file-based/src/pages/blog/page.tsx b/examples/router-file-based/src/pages/blog/page.tsx new file mode 100644 index 0000000..21882ef --- /dev/null +++ b/examples/router-file-based/src/pages/blog/page.tsx @@ -0,0 +1,28 @@ +import { Component } from '@geajs/core' +import { Link } from '@geajs/core' + +const POSTS = [ + { slug: 'getting-started', title: 'Getting Started with Gea', date: '2026-03-01', excerpt: 'Learn how to set up your first Gea project from scratch.' }, + { slug: 'file-based-routing', title: 'File-Based Routing in Gea', date: '2026-03-10', excerpt: 'Discover how router.setPath() automatically generates routes from your file system.' }, + { slug: 'reactive-stores', title: 'Reactive Stores', date: '2026-03-18', excerpt: 'A deep dive into how Gea tracks reactivity without a virtual DOM.' }, +] + +export default class BlogPage extends Component { + template() { + return ( +
+

Blog

+

The latest articles from the Gea team.

+
+ {POSTS.map((post) => ( + + +

{post.title}

+

{post.excerpt}

+ + ))} +
+
+ ) + } +} diff --git a/examples/router-file-based/src/pages/layout.tsx b/examples/router-file-based/src/pages/layout.tsx new file mode 100644 index 0000000..ad5b174 --- /dev/null +++ b/examples/router-file-based/src/pages/layout.tsx @@ -0,0 +1,20 @@ +import { Component } from '@geajs/core' +import { router, Link, Outlet } from '@geajs/core' + +export default class RootLayout extends Component { + template() { + return ( +
+ +
+ +
+
+ ) + } +} diff --git a/examples/router-file-based/src/pages/page.tsx b/examples/router-file-based/src/pages/page.tsx new file mode 100644 index 0000000..331e06c --- /dev/null +++ b/examples/router-file-based/src/pages/page.tsx @@ -0,0 +1,34 @@ +import { Component } from '@geajs/core' +import { Link } from '@geajs/core' + +export default class HomePage extends Component { + template() { + return ( +
+

Home

+

+ Welcome to the Gea file-based router example. Routes are automatically generated from + the pages/ directory — no manual route map required. +

+

+ Each page.tsx file becomes a route, and layout.tsx files + wrap their directory's routes with a shared layout. +

+
+ + About + Learn about this example + + + Blog + Read the latest posts + + + Users + Browse user profiles + +
+
+ ) + } +} diff --git a/examples/router-file-based/src/pages/users/[id]/page.tsx b/examples/router-file-based/src/pages/users/[id]/page.tsx new file mode 100644 index 0000000..5cb3fc5 --- /dev/null +++ b/examples/router-file-based/src/pages/users/[id]/page.tsx @@ -0,0 +1,42 @@ +import { Component } from '@geajs/core' +import { Link } from '@geajs/core' + +interface User { + id: string + name: string + role: string + bio: string +} + +const USERS: Record = { + '1': { id: '1', name: 'Alice', role: 'Engineer', bio: 'Loves building compilers and reactive frameworks.' }, + '2': { id: '2', name: 'Bob', role: 'Designer', bio: 'Passionate about minimal interfaces and typography.' }, + '3': { id: '3', name: 'Charlie', role: 'PM', bio: 'Keeps the trains running on time.' }, + '4': { id: '4', name: 'Dana', role: 'DevRel', bio: 'Makes developers feel welcome and builds great demos.' }, +} + +export default class UserProfilePage extends Component { + template({ id }: { id: string }) { + const user = USERS[id] + + if (!user) { + return ( +
+

User not found

+

No user with id {id}.

+ +
+ ) + } + + return ( + + ) + } +} diff --git a/examples/router-file-based/src/pages/users/page.tsx b/examples/router-file-based/src/pages/users/page.tsx new file mode 100644 index 0000000..eaef702 --- /dev/null +++ b/examples/router-file-based/src/pages/users/page.tsx @@ -0,0 +1,31 @@ +import { Component } from '@geajs/core' +import { Link } from '@geajs/core' + +const USERS = [ + { id: '1', name: 'Alice', role: 'Engineer' }, + { id: '2', name: 'Bob', role: 'Designer' }, + { id: '3', name: 'Charlie', role: 'PM' }, + { id: '4', name: 'Dana', role: 'DevRel' }, +] + +export default class UsersPage extends Component { + template() { + return ( +
+

Users

+

Click a user to view their profile.

+
+ {USERS.map((user) => ( + +
{user.name[0]}
+ + + ))} +
+
+ ) + } +} diff --git a/examples/router-file-based/src/styles.css b/examples/router-file-based/src/styles.css new file mode 100644 index 0000000..79131ce --- /dev/null +++ b/examples/router-file-based/src/styles.css @@ -0,0 +1,349 @@ +:root { + --bg-dark: #0f1419; + --bg-card: #1a2332; + --accent: #00d4aa; + --accent-dim: rgba(0, 212, 170, 0.15); + --text: #e7edf4; + --text-muted: #8b9cad; + --border: #2d3a4d; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: 'DM Sans', system-ui, sans-serif; + background: var(--bg-dark); + color: var(--text); + min-height: 100vh; +} + +#app { + max-width: 720px; + margin: 0 auto; + padding: 24px 20px; +} + +/* ── Nav ── */ + +.nav { + display: flex; + gap: 8px; + padding-bottom: 20px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.nav-link { + padding: 10px 18px; + border: 2px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + transition: border-color 0.15s, color 0.15s; +} + +.nav-link:hover { + border-color: var(--accent); + color: var(--accent); +} + +.nav-link.active { + border-color: var(--accent); + background: var(--accent-dim); + color: var(--accent); +} + +/* ── Content ── */ + +.content { + min-height: 300px; +} + +.view h1 { + font-size: 1.6rem; + font-weight: 700; + margin: 0 0 16px 0; +} + +.view h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 24px 0 8px 0; + color: var(--text); +} + +.view p { + color: var(--text-muted); + line-height: 1.6; + margin: 0 0 12px 0; +} + +.view ul { + color: var(--text-muted); + padding-left: 20px; + line-height: 1.8; +} + +.view code { + font-family: 'JetBrains Mono', monospace; + font-size: 0.85em; + background: var(--bg-card); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--border); +} + +/* ── Home cards ── */ + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; + margin-top: 24px; +} + +.card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px; + border: 2px solid var(--border); + border-radius: 10px; + text-decoration: none; + background: var(--bg-card); + transition: border-color 0.15s; +} + +.card:hover { + border-color: var(--accent); +} + +.card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text); +} + +.card-desc { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* ── Blog ── */ + +.post-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.post-card { + display: block; + padding: 16px 20px; + border: 2px solid var(--border); + border-radius: 10px; + text-decoration: none; + background: var(--bg-card); + transition: border-color 0.15s; +} + +.post-card:hover { + border-color: var(--accent); +} + +.post-date { + font-size: 0.8rem; + color: var(--text-muted); + font-family: 'JetBrains Mono', monospace; +} + +.post-title { + font-size: 1.1rem; + font-weight: 600; + margin: 6px 0 4px 0; + color: var(--text); +} + +.post-excerpt { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0; +} + +.post-body { + font-family: 'DM Sans', system-ui, sans-serif; + font-size: 0.95rem; + color: var(--text-muted); + line-height: 1.7; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + white-space: pre-wrap; + margin-top: 16px; +} + +/* ── Code block ── */ + +.code-block { + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 16px; + overflow-x: auto; + color: var(--accent); + white-space: pre; + margin: 12px 0; +} + +/* ── Users ── */ + +.user-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; +} + +.user-row { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + border: 2px solid var(--border); + border-radius: 10px; + text-decoration: none; + background: var(--bg-card); + transition: border-color 0.15s; +} + +.user-row:hover { + border-color: var(--accent); +} + +.avatar-sm { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--accent-dim); + color: var(--accent); + font-size: 1.1rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--accent); + flex-shrink: 0; +} + +.user-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.user-name { + font-weight: 600; + color: var(--text); +} + +/* ── User profile ── */ + +.user-profile { + text-align: center; +} + +.avatar { + width: 80px; + height: 80px; + border-radius: 50%; + background: var(--accent-dim); + color: var(--accent); + font-size: 2rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + margin: 16px auto; + border: 2px solid var(--accent); +} + +.role-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 20px; + background: var(--accent-dim); + color: var(--accent); + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 16px; +} + +/* ── Shared ── */ + +.role { + font-size: 0.82rem; + color: var(--text-muted); +} + +.back-link { + display: inline-block; + margin-bottom: 16px; + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; +} + +.back-link:hover { + text-decoration: underline; +} + +/* ── 404 ── */ + +.not-found { + text-align: center; + padding-top: 40px; +} + +.not-found h1 { + font-size: 5rem; + font-weight: 800; + color: var(--accent); + margin-bottom: 8px; +} + +/* ── Error page ── */ + +.error-page { + text-align: center; + padding: 40px 20px; +} + +.error-page h1 { + font-size: 1.5rem; + margin-bottom: 12px; +} + +.error-page button { + margin-top: 16px; + padding: 10px 20px; + border: 2px solid var(--accent); + border-radius: 8px; + background: transparent; + color: var(--accent); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; +} diff --git a/examples/router-file-based/tsconfig.json b/examples/router-file-based/tsconfig.json new file mode 100644 index 0000000..36e2f0b --- /dev/null +++ b/examples/router-file-based/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../packages/gea/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@geajs/core": [ + "../../packages/gea/src" + ] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.d.ts", + "../../packages/vite-plugin-gea/gea-env.d.ts" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/examples/router-file-based/vite.config.ts b/examples/router-file-based/vite.config.ts new file mode 100644 index 0000000..d177a81 --- /dev/null +++ b/examples/router-file-based/vite.config.ts @@ -0,0 +1,20 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import { geaPlugin } from '../../packages/vite-plugin-gea/src/index.ts' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + root: __dirname, + plugins: [geaPlugin()], + resolve: { + alias: { + '@geajs/core': resolve(__dirname, '../../packages/gea/src'), + }, + }, + server: { + port: 5312, + open: true, + }, +}) diff --git a/package-lock.json b/package-lock.json index 51c5eb5..748e8a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -189,6 +189,7 @@ "integrity": "sha512-Jc360x4yqb3eEg4OY4KEIdGePBxZogivKI+OGIU8aLXgAYPTECvzeOBc90312yHA1hr3AeRlAFl0rIc8lQaIrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.50.0", "@algolia/requester-browser-xhr": "5.50.0", @@ -967,6 +968,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1015,6 +1017,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2118,7 +2121,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2135,7 +2137,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2152,7 +2153,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2169,7 +2169,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2186,7 +2185,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2203,7 +2201,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2220,7 +2217,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2237,7 +2233,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2254,7 +2249,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2271,7 +2265,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2288,7 +2281,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2305,7 +2297,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2319,7 +2310,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, @@ -2339,7 +2329,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2356,7 +2345,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -2365,8 +2353,7 @@ "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@rollup/plugin-commonjs": { "version": "29.0.2", @@ -3100,6 +3087,7 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -4114,6 +4102,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4154,6 +4143,7 @@ "integrity": "sha512-yE5I83Q2s8euVou8Y3feXK08wyZInJWLYXgWO6Xti9jBUEZAGUahyeQ7wSZWkifLWVnQVKEz5RAmBlXG5nqxog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.16.0", "@algolia/client-abtesting": "5.50.0", @@ -4519,6 +4509,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5205,6 +5196,7 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5269,6 +5261,7 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5739,6 +5732,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -6520,7 +6514,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6541,7 +6534,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6562,7 +6554,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6583,7 +6574,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6604,7 +6594,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6625,7 +6614,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6646,7 +6634,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6667,7 +6654,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6688,7 +6674,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6709,7 +6694,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6730,7 +6714,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8095,6 +8078,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8814,6 +8798,7 @@ "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -8943,6 +8928,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10024,6 +10010,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10758,7 +10745,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -11299,6 +11285,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11362,47 +11349,6 @@ "node": ">=14.0.0" } }, - "node_modules/vscode-languageclient": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-8.1.0.tgz", - "integrity": "sha512-GL4QdbYUF/XxQlAsvYWZRV3V34kOkpRlvV60/72ghHfsYFnS/v2MANZ9P6sHmxFcZKOse8O+L9G7Czg0NUWing==", - "license": "MIT", - "dependencies": { - "minimatch": "^5.1.0", - "semver": "^7.3.7", - "vscode-languageserver-protocol": "3.17.3" - }, - "engines": { - "vscode": "^1.67.0" - } - }, - "node_modules/vscode-languageclient/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/vscode-languageclient/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/vscode-languageclient/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/vscode-languageserver": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-8.1.0.tgz", @@ -11443,6 +11389,7 @@ "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/compiler-sfc": "3.5.31", @@ -11647,7 +11594,7 @@ }, "packages/gea": { "name": "@geajs/core", - "version": "1.0.7", + "version": "1.0.9", "license": "MIT", "dependencies": { "@types/react": "^19.0.0" @@ -11715,7 +11662,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "vscode-languageclient": "^8.1.0", + "vscode-languageclient": "^6.1.4", "vscode-languageserver": "^8.1.0", "vscode-languageserver-textdocument": "^1.0.8" }, @@ -11736,14 +11683,61 @@ "undici-types": "~6.21.0" } }, + "packages/gea-tools/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "packages/gea-tools/node_modules/undici-types": { "version": "6.21.0", "dev": true, "license": "MIT" }, + "packages/gea-tools/node_modules/vscode-jsonrpc": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-5.0.1.tgz", + "integrity": "sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==", + "license": "MIT", + "engines": { + "node": ">=8.0.0 || >=10.0.0" + } + }, + "packages/gea-tools/node_modules/vscode-languageclient": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-6.1.4.tgz", + "integrity": "sha512-EUOU+bJu6axmt0RFNo3nrglQLPXMfanbYViJee3Fbn2VuQoX0ZOI4uTYhSRvYLP2vfwTP/juV62P/mksCdTZMA==", + "license": "MIT", + "dependencies": { + "semver": "^6.3.0", + "vscode-languageserver-protocol": "3.15.3" + }, + "engines": { + "vscode": "^1.41.0" + } + }, + "packages/gea-tools/node_modules/vscode-languageserver-protocol": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.15.3.tgz", + "integrity": "sha512-zrMuwHOAQRhjDSnflWdJG+O2ztMWss8GqUUB8dXLR/FPenwkiBNkMIJJYfSN6sgskvsF0rHAoBowNQfbyZnnvw==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "^5.0.1", + "vscode-languageserver-types": "3.15.1" + } + }, + "packages/gea-tools/node_modules/vscode-languageserver-types": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.15.1.tgz", + "integrity": "sha512-+a9MPUQrNGRrGU630OGbYVQ+11iOIovjCkqxajPa9w57Sd5ruK8WQNsslzpa0x/QJqC8kRc2DUxWjIFwoNm4ZQ==", + "license": "MIT" + }, "packages/gea-ui": { "name": "@geajs/ui", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@zag-js/accordion": "^1.37.0", @@ -11788,7 +11782,7 @@ "typescript": "^5.9.3" }, "peerDependencies": { - "@geajs/core": "^1.0.6" + "@geajs/core": "^1.0.8" } }, "packages/gea-ui/node_modules/@types/react": { @@ -11820,7 +11814,7 @@ }, "packages/vite-plugin-gea": { "name": "@geajs/vite-plugin", - "version": "1.0.7", + "version": "1.0.9", "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.31", diff --git a/package.json b/package.json index ce6d254..0834eac 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "example:router-simple": "vite dev examples/router-simple", "example:router-v2": "vite dev examples/router-v2", "example:tic-tac-toe": "vite dev --config examples/tic-tac-toe/vite.config.ts", + "example:router-file-based": "vite dev examples/router-file-based", "example:todo": "vite dev examples/todo", "test:e2e": "npx playwright test --config=tests/e2e/playwright.config.ts" }, diff --git a/packages/gea-tools/package.json b/packages/gea-tools/package.json index 0d93dc6..cd627c1 100644 --- a/packages/gea-tools/package.json +++ b/packages/gea-tools/package.json @@ -41,9 +41,9 @@ "watch": "tsc -w -p ./" }, "dependencies": { + "vscode-languageclient": "^8.1.0", "vscode-languageserver": "^8.1.0", - "vscode-languageserver-textdocument": "^1.0.8", - "vscode-languageclient": "^8.1.0" + "vscode-languageserver-textdocument": "^1.0.8" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/packages/gea/src/index.ts b/packages/gea/src/index.ts index 805ce9b..5e5b777 100644 --- a/packages/gea/src/index.ts +++ b/packages/gea/src/index.ts @@ -7,7 +7,7 @@ export type { DOMEvent } from './lib/types' export { default as ComponentManager } from './lib/base/component-manager' export { applyListChanges } from './lib/base/list' export type { ListConfig } from './lib/base/list' -export { createRouter, Router, router, matchRoute, Link, Outlet, RouterView } from './lib/router' +export { createRouter, Router, router, matchRoute, buildFileRoutes, Link, Outlet, RouterView } from './lib/router' export type { RouteMap, RouteEntry, diff --git a/packages/gea/src/lib/router/file-routes.ts b/packages/gea/src/lib/router/file-routes.ts new file mode 100644 index 0000000..b1031dc --- /dev/null +++ b/packages/gea/src/lib/router/file-routes.ts @@ -0,0 +1,279 @@ +import type { RouteMap, RouteGroupConfig, LazyComponent } from './types' + +// ── Path conversion ────────────────────────────────────────────── + +/** + * Convert a glob file path for a page file into a route key. + * + * Conventions: + * ./pages/page.tsx → '/' + * ./pages/about/page.tsx → '/about' + * ./pages/users/[id]/page.tsx → '/users/:id' + * ./pages/[...all]/page.tsx → '*' + * ./pages/blog/[...slug]/page.tsx → '/blog/*' + */ +function pageFileToRoute(filePath: string, basePath: string): string { + const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath + let path = filePath.slice(base.length) + + // Remove /page.{ext} suffix + path = path.replace(/\/page\.(tsx|ts|jsx|js)$/, '') + + // Catch-all [...name] as the last directory segment + const catchAll = path.match(/^(.*?)\/\[\.\.\.([^\]]+)\]$/) + if (catchAll) { + const prefix = catchAll[1] + if (!prefix) return '*' + return prefix.replace(/\[([^\]]+)\]/g, ':$1') + '/*' + } + + if (!path || path === '/') return '/' + + // [param] → :param + return path.replace(/\[([^\]]+)\]/g, ':$1') +} + +/** + * Convert a glob file path for a layout file into the route prefix it covers. + * + * ./pages/layout.tsx → '/' + * ./pages/users/layout.tsx → '/users' + * ./pages/users/[id]/layout.tsx → '/users/:id' + */ +function layoutFileToPrefix(filePath: string, basePath: string): string { + const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath + let path = filePath.slice(base.length) + + // Remove /layout.{ext} suffix + path = path.replace(/\/layout\.(tsx|ts|jsx|js)$/, '') + + if (!path || path === '/') return '/' + + return path.replace(/\[([^\]]+)\]/g, ':$1') +} + +/** + * Raw filesystem prefix for a layout file — like layoutFileToPrefix but keeps + * `[param]` segments as-is (no conversion to `:param`). Used for tree-ancestry + * checks so dynamic layout dirs don't claim unrelated sibling pages. + */ +function layoutFileToFsPrefix(filePath: string, basePath: string): string { + const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath + let path = filePath.slice(base.length) + path = path.replace(/\/layout\.(tsx|ts|jsx|js)$/, '') + if (!path || path === '/') return '/' + return path +} + +/** + * Raw filesystem path for a page file — strips `/page.ext` but keeps + * `[param]` segments as-is. Used for layout-ownership checks. + */ +function pageFileToFsPath(filePath: string, basePath: string): string { + const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath + let path = filePath.slice(base.length) + path = path.replace(/\/page\.(tsx|ts|jsx|js)$/, '') + if (!path || path === '/') return '/' + return path +} + +// ── Filesystem-path ancestry (literal segment matching) ────────── + +/** True if `ancestorFsPrefix` is a strict filesystem ancestor of `childFsPrefix`. */ +function isFsAncestor(ancestorFsPrefix: string, childFsPrefix: string): boolean { + if (ancestorFsPrefix === childFsPrefix) return false + if (ancestorFsPrefix === '/') return true + return childFsPrefix.startsWith(ancestorFsPrefix + '/') +} + +/** True if `pageFsPath` is at or under `layoutFsPrefix` in the filesystem tree. */ +function isFsUnderPrefix(pageFsPath: string, layoutFsPrefix: string): boolean { + if (layoutFsPrefix === '/') return true + return pageFsPath === layoutFsPrefix || pageFsPath.startsWith(layoutFsPrefix + '/') +} + +// ── Relative path ──────────────────────────────────────────────── + +/** + * Compute the path of `fullRoute` relative to `prefix`. + * + * relativePath('/users/:id', '/users') → '/:id' + * relativePath('/users', '/users') → '/' + * relativePath('/about', '/') → '/about' (root: no stripping) + */ +function relativePath(fullRoute: string, prefix: string): string { + if (prefix === '/') return fullRoute // root layout: children use full paths + + if (fullRoute === prefix) return '/' + + const prefixDepth = prefix.split('/').filter(Boolean).length + const routeParts = fullRoute.split('/').filter(Boolean) + return '/' + routeParts.slice(prefixDepth).join('/') || '/' +} + +// ── Layout tree ────────────────────────────────────────────────── + +interface LayoutNode { + fsPrefix: string // raw filesystem prefix, e.g. '/users/[id]' + patternPrefix: string // route-pattern prefix, e.g. '/users/:id' + layout: any + children: LayoutNode[] +} + +function buildLayoutTree( + sortedLayouts: Array<{ fsPrefix: string; patternPrefix: string; layout: any }>, +): LayoutNode[] { + const nodes: LayoutNode[] = sortedLayouts.map(({ fsPrefix, patternPrefix, layout }) => ({ + fsPrefix, + patternPrefix, + layout, + children: [], + })) + + const roots: LayoutNode[] = [] + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + let parent: LayoutNode | null = null + + for (let j = 0; j < i; j++) { + const candidate = nodes[j] + if (isFsAncestor(candidate.fsPrefix, node.fsPrefix)) { + // Pick the deepest filesystem ancestor + if (!parent || candidate.fsPrefix.length > parent.fsPrefix.length) { + parent = candidate + } + } + } + + if (parent) { + parent.children.push(node) + } else { + roots.push(node) + } + } + + return roots +} + +// ── RouteMap builder ───────────────────────────────────────────── + +/** True if `page` belongs directly to `node` (not to a child layout). */ +function belongsToNode( + page: { route: string; fsPath: string }, + node: LayoutNode, +): boolean { + if (!isFsUnderPrefix(page.fsPath, node.fsPrefix)) return false + // Must not fall under any child layout + for (const child of node.children) { + if (isFsUnderPrefix(page.fsPath, child.fsPrefix)) return false + } + return true +} + +function buildGroupChildren( + node: LayoutNode, + pages: Array<{ route: string; fsPath: string; loader: LazyComponent }>, +): RouteMap { + const children: Record = {} + + // Pages owned directly by this node + for (const page of pages) { + if (!belongsToNode(page, node)) continue + children[relativePath(page.route, node.patternPrefix)] = page.loader + } + + // Nested layout groups + for (const child of node.children) { + const key = relativePath(child.patternPrefix, node.patternPrefix) + const group: RouteGroupConfig = { + layout: child.layout, + children: buildGroupChildren(child, pages), + } + children[key] = group + } + + return children as RouteMap +} + +function buildNestedRouteMap( + pages: Array<{ route: string; fsPath: string; loader: LazyComponent }>, + sortedLayouts: Array<{ fsPrefix: string; patternPrefix: string; layout: any }>, +): RouteMap { + const roots = buildLayoutTree(sortedLayouts) + const result: Record = {} + + // Pages not under any root layout node + for (const page of pages) { + const underRoot = roots.some((r) => isFsUnderPrefix(page.fsPath, r.fsPrefix)) + if (!underRoot) result[page.route] = page.loader + } + + // Layout groups + for (const root of roots) { + const group: RouteGroupConfig = { + layout: root.layout, + children: buildGroupChildren(root, pages), + } + result[root.patternPrefix] = group + } + + return result as RouteMap +} + +// ── Public API ─────────────────────────────────────────────────── + +/** + * Builds a `RouteMap` from Vite `import.meta.glob` results. + * + * This function is called automatically by the Gea Vite plugin when you use + * `router.setPath('./pages')`. Do not call it directly. + * + * File conventions (Next.js App Router style): + * - `page.tsx` — the page component for a route + * - `layout.tsx` — wraps all routes in the same directory (and sub-directories) + * - `[param]/` — dynamic route segment → `:param` + * - `[...slug]/` — catch-all segment → `*` + * + * @param pageGlob `import.meta.glob('.../page.{tsx,ts,jsx,js}')` + * @param layoutGlob `import.meta.glob('.../layout.{tsx,ts,jsx,js}', { eager: true })` + * @param basePath The base directory (e.g. `'./pages'`) + */ +export function buildFileRoutes( + pageGlob: Record Promise>, + layoutGlob: Record, + basePath: string, +): RouteMap { + const pages: Array<{ route: string; fsPath: string; loader: LazyComponent }> = [] + for (const [filePath, loader] of Object.entries(pageGlob)) { + pages.push({ + route: pageFileToRoute(filePath, basePath), + fsPath: pageFileToFsPath(filePath, basePath), + loader, + }) + } + + const layoutsList: Array<{ fsPrefix: string; patternPrefix: string; layout: any }> = [] + for (const [filePath, mod] of Object.entries(layoutGlob)) { + layoutsList.push({ + fsPrefix: layoutFileToFsPrefix(filePath, basePath), + patternPrefix: layoutFileToPrefix(filePath, basePath), + layout: mod.default ?? mod, + }) + } + + if (layoutsList.length === 0) { + // No layouts — flat map + const flat: Record = {} + for (const { route, loader } of pages) flat[route] = loader + return flat as RouteMap + } + + const sortedLayouts = layoutsList.sort((a, b) => { + const da = a.fsPrefix === '/' ? 0 : a.fsPrefix.split('/').filter(Boolean).length + const db = b.fsPrefix === '/' ? 0 : b.fsPrefix.split('/').filter(Boolean).length + return da - db + }) + + return buildNestedRouteMap(pages, sortedLayouts) +} diff --git a/packages/gea/src/lib/router/index.ts b/packages/gea/src/lib/router/index.ts index a0e5ffe..3f497a8 100644 --- a/packages/gea/src/lib/router/index.ts +++ b/packages/gea/src/lib/router/index.ts @@ -32,6 +32,7 @@ export { Link } export { Outlet } export { RouterView } export { matchRoute } from './match' +export { buildFileRoutes } from './file-routes' export type { RouteMap, RouteEntry, diff --git a/packages/gea/src/lib/router/router.ts b/packages/gea/src/lib/router/router.ts index beb84b1..954c392 100644 --- a/packages/gea/src/lib/router/router.ts +++ b/packages/gea/src/lib/router/router.ts @@ -120,6 +120,29 @@ export class Router extends Store { if (typeof window !== 'undefined') this._resolve() } + /** + * Enable file-based routing from a directory. + * + * This method is transformed at build time by `@geajs/vite-plugin` into a + * `setRoutes()` call powered by `import.meta.glob`. If you see this error + * at runtime, make sure `geaPlugin()` is configured in your `vite.config.ts`. + * + * Conventions: + * - `page.tsx` — page component for the route + * - `layout.tsx` — layout wrapping all routes in this directory + * - `[param]/` — dynamic segment (→ `:param`) + * - `[...slug]/` — catch-all segment (→ `*`) + * + * @example + * router.setPath('./pages') + */ + setPath(_path: string): void { + throw new Error( + '[gea] router.setPath() must be transformed by @geajs/vite-plugin. ' + + 'Ensure geaPlugin() is added to your vite.config.ts.', + ) + } + get page(): any { return this._guardComponent ?? this._currentComponent } diff --git a/packages/gea/tests/router-file-routes.test.ts b/packages/gea/tests/router-file-routes.test.ts new file mode 100644 index 0000000..3189ee3 --- /dev/null +++ b/packages/gea/tests/router-file-routes.test.ts @@ -0,0 +1,332 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { buildFileRoutes } from '../src/lib/router/file-routes' + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const lazy = (name: string) => () => Promise.resolve({ default: { name } as any }) +const sync = (name: string) => ({ default: { name } as any }) + +// ── pageFileToRoute (tested indirectly via buildFileRoutes) ────────────────── + +describe('buildFileRoutes – flat (no layouts)', () => { + it('maps root page to "/"', () => { + const routes = buildFileRoutes( + { './pages/page.tsx': lazy('Home') }, + {}, + './pages', + ) + assert.ok('/' in routes, 'should have "/" key') + }) + + it('maps nested page to "/about"', () => { + const routes = buildFileRoutes( + { './pages/about/page.tsx': lazy('About') }, + {}, + './pages', + ) + assert.ok('/about' in routes) + }) + + it('maps dynamic segment [id] to ":id"', () => { + const routes = buildFileRoutes( + { './pages/users/[id]/page.tsx': lazy('UserDetail') }, + {}, + './pages', + ) + assert.ok('/users/:id' in routes) + }) + + it('maps top-level catch-all [...all] to "*"', () => { + const routes = buildFileRoutes( + { './pages/[...all]/page.tsx': lazy('NotFound') }, + {}, + './pages', + ) + assert.ok('*' in routes) + }) + + it('maps nested catch-all [...slug] to "/blog/*"', () => { + const routes = buildFileRoutes( + { './pages/blog/[...slug]/page.tsx': lazy('BlogCatchAll') }, + {}, + './pages', + ) + assert.ok('/blog/*' in routes) + }) + + it('maps multiple pages without layouts', () => { + const routes = buildFileRoutes( + { + './pages/page.tsx': lazy('Home'), + './pages/about/page.tsx': lazy('About'), + './pages/contact/page.tsx': lazy('Contact'), + }, + {}, + './pages', + ) + assert.ok('/' in routes) + assert.ok('/about' in routes) + assert.ok('/contact' in routes) + assert.equal(Object.keys(routes).length, 3) + }) + + it('preserves the loader function reference', () => { + const loader = lazy('Home') + const routes = buildFileRoutes({ './pages/page.tsx': loader }, {}, './pages') + assert.equal(routes['/'] as unknown, loader) + }) + + it('works with basePath that has a trailing slash', () => { + const routes = buildFileRoutes( + { './pages/about/page.tsx': lazy('About') }, + {}, + './pages/', + ) + assert.ok('/about' in routes) + }) + + it('supports .ts extensions', () => { + const routes = buildFileRoutes( + { './pages/page.ts': lazy('Home') }, + {}, + './pages', + ) + assert.ok('/' in routes) + }) + + it('supports .js and .jsx extensions', () => { + const routes = buildFileRoutes( + { + './pages/page.js': lazy('HomeJS'), + './pages/about/page.jsx': lazy('AboutJSX'), + }, + {}, + './pages', + ) + assert.ok('/' in routes) + assert.ok('/about' in routes) + }) +}) + +// ── With root layout ────────────────────────────────────────────────────────── + +describe('buildFileRoutes – with root layout', () => { + it('wraps all pages in a root layout group', () => { + const RootLayout = sync('RootLayout') + const routes = buildFileRoutes( + { + './pages/page.tsx': lazy('Home'), + './pages/about/page.tsx': lazy('About'), + }, + { './pages/layout.tsx': RootLayout }, + './pages', + ) + // Root layout group is at '/' + const group = routes['/'] as any + assert.ok(group && typeof group === 'object', 'should have a root group') + assert.ok('layout' in group, 'group should have layout') + assert.ok('children' in group, 'group should have children') + assert.equal(group.layout, RootLayout.default) + }) + + it('root layout children include "/" and "/about" as full paths (root prefix keeps full paths)', () => { + const RootLayout = sync('RootLayout') + const routes = buildFileRoutes( + { + './pages/page.tsx': lazy('Home'), + './pages/about/page.tsx': lazy('About'), + }, + { './pages/layout.tsx': RootLayout }, + './pages', + ) + const group = routes['/'] as any + assert.ok('/' in group.children, 'children should contain "/"') + assert.ok('/about' in group.children, 'children should contain "/about"') + }) + + it('catch-all is placed in root layout children', () => { + const RootLayout = sync('RootLayout') + const routes = buildFileRoutes( + { + './pages/page.tsx': lazy('Home'), + './pages/[...all]/page.tsx': lazy('NotFound'), + }, + { './pages/layout.tsx': RootLayout }, + './pages', + ) + const group = routes['/'] as any + // bare wildcard '*' is NOT under a root layout (isUnderPrefix returns false for '*' when prefix is '/') + // Actually per the implementation: isUnderPrefix(route='*', prefix='/') returns false + // So '*' pages are placed outside layouts + assert.ok('*' in routes || '*' in (group?.children ?? {}), 'catch-all should exist somewhere') + }) +}) + +// ── With nested layouts ─────────────────────────────────────────────────────── + +describe('buildFileRoutes – with nested layouts', () => { + it('creates nested layout group under root', () => { + const RootLayout = sync('RootLayout') + const UsersLayout = sync('UsersLayout') + const routes = buildFileRoutes( + { + './pages/page.tsx': lazy('Home'), + './pages/users/page.tsx': lazy('UsersList'), + './pages/users/[id]/page.tsx': lazy('UserDetail'), + }, + { + './pages/layout.tsx': RootLayout, + './pages/users/layout.tsx': UsersLayout, + }, + './pages', + ) + const rootGroup = routes['/'] as any + assert.ok(rootGroup, 'root group should exist') + // Users layout is a child layout under root + const usersGroup = rootGroup.children['/users'] as any + assert.ok(usersGroup, 'users group should be nested inside root') + assert.equal(usersGroup.layout, UsersLayout.default) + assert.ok('children' in usersGroup) + }) + + it('users layout children use relative paths', () => { + const RootLayout = sync('RootLayout') + const UsersLayout = sync('UsersLayout') + const routes = buildFileRoutes( + { + './pages/page.tsx': lazy('Home'), + './pages/users/page.tsx': lazy('UsersList'), + './pages/users/[id]/page.tsx': lazy('UserDetail'), + }, + { + './pages/layout.tsx': RootLayout, + './pages/users/layout.tsx': UsersLayout, + }, + './pages', + ) + const rootGroup = routes['/'] as any + const usersGroup = rootGroup.children['/users'] as any + // Children relative to /users: '/' and '/:id' + assert.ok('/' in usersGroup.children, 'users list should map to "/" inside users group') + assert.ok('/:id' in usersGroup.children, 'user detail should map to "/:id" inside users group') + }) + + it('sibling layout groups do not bleed into each other', () => { + const RootLayout = sync('RootLayout') + const BlogLayout = sync('BlogLayout') + const UsersLayout = sync('UsersLayout') + const routes = buildFileRoutes( + { + './pages/page.tsx': lazy('Home'), + './pages/blog/page.tsx': lazy('BlogList'), + './pages/blog/[slug]/page.tsx': lazy('BlogPost'), + './pages/users/page.tsx': lazy('UsersList'), + }, + { + './pages/layout.tsx': RootLayout, + './pages/blog/layout.tsx': BlogLayout, + './pages/users/layout.tsx': UsersLayout, + }, + './pages', + ) + const rootChildren = (routes['/'] as any).children + assert.ok('/blog' in rootChildren, 'blog group should be under root') + assert.ok('/users' in rootChildren, 'users group should be under root') + const blogChildren = rootChildren['/blog'].children + assert.ok(!('/users' in blogChildren), 'users routes should not be inside blog group') + }) + + it('deeply nested layout (3 levels)', () => { + const RootLayout = sync('RootLayout') + const SettingsLayout = sync('SettingsLayout') + const ProfileLayout = sync('ProfileLayout') + const routes = buildFileRoutes( + { + './pages/page.tsx': lazy('Home'), + './pages/settings/page.tsx': lazy('Settings'), + './pages/settings/profile/page.tsx': lazy('Profile'), + './pages/settings/profile/edit/page.tsx': lazy('EditProfile'), + }, + { + './pages/layout.tsx': RootLayout, + './pages/settings/layout.tsx': SettingsLayout, + './pages/settings/profile/layout.tsx': ProfileLayout, + }, + './pages', + ) + const rootChildren = (routes['/'] as any).children + const settingsGroup = rootChildren['/settings'] as any + assert.ok(settingsGroup, 'settings group should exist') + const profileGroup = settingsGroup.children['/profile'] as any + assert.ok(profileGroup, 'profile group should be nested inside settings') + assert.ok('/' in profileGroup.children, 'profile index should exist') + assert.ok('/edit' in profileGroup.children, 'edit page should be under profile') + }) +}) + +// ── Layout without pages (layout only, no sub-pages) ──────────────────────── + +describe('buildFileRoutes – edge cases', () => { + it('returns empty object when both globs are empty', () => { + const routes = buildFileRoutes({}, {}, './pages') + assert.deepEqual(routes, {}) + }) + + it('handles layout module with .default property', () => { + const LayoutClass = { name: 'Layout' } as any + const routes = buildFileRoutes( + { './pages/page.tsx': lazy('Home') }, + { './pages/layout.tsx': { default: LayoutClass } }, + './pages', + ) + const group = routes['/'] as any + assert.equal(group.layout, LayoutClass) + }) + + it('handles layout module without .default (direct export)', () => { + const LayoutClass = { name: 'Layout' } as any + const routes = buildFileRoutes( + { './pages/page.tsx': lazy('Home') }, + { './pages/layout.tsx': LayoutClass }, + './pages', + ) + const group = routes['/'] as any + assert.equal(group.layout, LayoutClass) + }) + + it('multiple dynamic segments in file path', () => { + const routes = buildFileRoutes( + { './pages/orgs/[org]/repos/[repo]/page.tsx': lazy('RepoDetail') }, + {}, + './pages', + ) + assert.ok('/orgs/:org/repos/:repo' in routes) + }) + + it('standalone layout with no pages still creates empty group', () => { + const RootLayout = sync('RootLayout') + const routes = buildFileRoutes( + {}, + { './pages/layout.tsx': RootLayout }, + './pages', + ) + const group = routes['/'] as any + assert.ok(group && typeof group === 'object') + assert.deepEqual(group.children, {}) + }) + + it('page outside any layout remains a flat entry', () => { + const UsersLayout = sync('UsersLayout') + const routes = buildFileRoutes( + { + './pages/about/page.tsx': lazy('About'), + './pages/users/page.tsx': lazy('UsersList'), + }, + { './pages/users/layout.tsx': UsersLayout }, + './pages', + ) + // '/about' is not under users layout — should be a flat entry + assert.ok('/about' in routes, 'about should be a top-level flat entry') + }) +}) diff --git a/packages/vite-plugin-gea/src/index.ts b/packages/vite-plugin-gea/src/index.ts index 44108da..0738875 100644 --- a/packages/vite-plugin-gea/src/index.ts +++ b/packages/vite-plugin-gea/src/index.ts @@ -5,6 +5,7 @@ import { parseSource } from './parse.ts' import { injectHMR } from './hmr.ts' import { transformComponentFile, transformNonComponentJSX } from './transform-component.ts' import { convertFunctionalToClass } from './transform-functional.ts' +import { transformFileRoutes } from './transform-file-routes.ts' import { isComponentTag } from './utils.ts' import { dirname, relative, resolve } from 'node:path' import { existsSync, readFileSync, writeFileSync } from 'node:fs' @@ -412,20 +413,29 @@ export function geaPlugin(): Plugin { } } - if (/\bclass\s+Component\s+extends\s+Store\b/.test(code)) return null + // File-based routing: transform .setPath('./dir') → .setRoutes(buildFileRoutes(...)) + let fileRouteResult: { code: string; map: null } | null = null + if (code.includes('.setPath(')) { + fileRouteResult = transformFileRoutes(code) + if (fileRouteResult) code = fileRouteResult.code + } + + if (/\bclass\s+Component\s+extends\s+Store\b/.test(code)) { + return fileRouteResult + } const hasAngleBrackets = code.includes('<') && code.includes('>') - if (!hasAngleBrackets) return null + if (!hasAngleBrackets) return fileRouteResult try { const parsed = parseSource(code) - if (!parsed) return null + if (!parsed) return fileRouteResult const { functionalComponentInfo, hasJSX } = parsed let { ast, componentClassName, imports } = parsed let { componentClassNames } = parsed - if (!hasJSX) return null + if (!hasJSX) return fileRouteResult if (functionalComponentInfo) { convertFunctionalToClass(ast, functionalComponentInfo, imports) @@ -543,7 +553,7 @@ export function geaPlugin(): Plugin { if (hmrAdded) transformed = true } - if (!transformed) return null + if (!transformed) return fileRouteResult const output = generate(ast, { sourceMaps: true, sourceFileName: cleanId }, code) return { code: output.code, map: output.map } } catch (error: any) { diff --git a/packages/vite-plugin-gea/src/transform-file-routes.ts b/packages/vite-plugin-gea/src/transform-file-routes.ts new file mode 100644 index 0000000..33aac3a --- /dev/null +++ b/packages/vite-plugin-gea/src/transform-file-routes.ts @@ -0,0 +1,117 @@ +/** + * Transforms `router.setPath('./pages')` calls into the expanded form: + * + * router.setRoutes( + * __geaBuildFileRoutes( + * import.meta.glob('./pages/** /page.{tsx,ts,jsx,js}'), + * import.meta.glob('./pages/** /layout.{tsx,ts,jsx,js}', { eager: true }), + * './pages' + * ) + * ) + * + * The `buildFileRoutes` helper is imported from `@geajs/core` under the + * `__geaBuildFileRoutes` alias to avoid collisions with user code. + * + * Uses an AST-based approach so that template literals, strings in comments, + * and other non-call occurrences are never accidentally mutated. + */ + +import { parse } from '@babel/parser' +import * as t from '@babel/types' +import { createRequire } from 'module' + +const require = createRequire(import.meta.url) +const traverse = require('@babel/traverse').default +const generate = require('@babel/generator').default + +const IMPORT_MARKER = '__geaBuildFileRoutes' +const IMPORT_SOURCE = '@geajs/core' + +function buildGlobCall(pattern: string, options?: t.ObjectExpression): t.CallExpression { + const callee = t.memberExpression( + t.metaProperty(t.identifier('import'), t.identifier('meta')), + t.identifier('glob'), + ) + const args: t.Expression[] = [t.stringLiteral(pattern)] + if (options) args.push(options) + return t.callExpression(callee, args) +} + +export function transformFileRoutes(code: string): { code: string; map: null } | null { + if (!code.includes('.setPath(')) return null + + let ast: t.File + try { + ast = parse(code, { + sourceType: 'module', + plugins: ['typescript', 'jsx', 'decorators-legacy', 'classProperties'], + }) + } catch { + return null + } + + let hasSetPath = false + let alreadyImported = false + + traverse(ast, { + ImportDeclaration(path: any) { + if (path.node.source.value !== IMPORT_SOURCE) return + for (const spec of path.node.specifiers) { + if ( + t.isImportSpecifier(spec) && + t.isIdentifier(spec.imported) && + spec.imported.name === 'buildFileRoutes' && + spec.local.name === IMPORT_MARKER + ) { + alreadyImported = true + } + } + }, + + CallExpression(path: any) { + const node = path.node as t.CallExpression + if ( + !t.isMemberExpression(node.callee) || + !t.isIdentifier(node.callee.property) || + node.callee.property.name !== 'setPath' || + node.arguments.length < 1 || + !t.isStringLiteral(node.arguments[0]) || + !/^\.{1,2}\//.test((node.arguments[0] as t.StringLiteral).value) + ) { + return + } + + hasSetPath = true + const dirPath = (node.arguments[0] as t.StringLiteral).value + + const eagerOpts = t.objectExpression([ + t.objectProperty(t.identifier('eager'), t.booleanLiteral(true)), + ]) + + const buildCall = t.callExpression(t.identifier(IMPORT_MARKER), [ + buildGlobCall(`${dirPath}/**/page.{tsx,ts,jsx,js}`), + buildGlobCall(`${dirPath}/**/layout.{tsx,ts,jsx,js}`, eagerOpts), + t.stringLiteral(dirPath), + ]) + + path.replaceWith( + t.callExpression(t.memberExpression(node.callee.object, t.identifier('setRoutes')), [ + buildCall, + ]), + ) + }, + }) + + if (!hasSetPath) return null + + if (!alreadyImported) { + const importDecl = t.importDeclaration( + [t.importSpecifier(t.identifier(IMPORT_MARKER), t.identifier('buildFileRoutes'))], + t.stringLiteral(IMPORT_SOURCE), + ) + ast.program.body.unshift(importDecl) + } + + const { code: generated } = generate(ast, {}, code) + return { code: generated, map: null } +} diff --git a/packages/vite-plugin-gea/tests/transform-file-routes.test.ts b/packages/vite-plugin-gea/tests/transform-file-routes.test.ts new file mode 100644 index 0000000..4161a17 --- /dev/null +++ b/packages/vite-plugin-gea/tests/transform-file-routes.test.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { transformFileRoutes } from '../src/transform-file-routes' + +const IMPORT_STMT = `import { buildFileRoutes as __geaBuildFileRoutes } from '@geajs/core';` + +describe('transformFileRoutes – no-op cases', () => { + it('returns null when code has no .setPath(', () => { + const result = transformFileRoutes(`router.setRoutes({})`) + assert.equal(result, null) + }) + + it('returns null when .setPath( appears but not as a relative path', () => { + const result = transformFileRoutes(`router.setPath('pages')`) // no leading ./ or ../ + assert.equal(result, null) + }) + + it('returns null when .setPath( is inside a JSDoc comment line', () => { + const code = ` +/** + * Call router.setPath('./pages') to enable file routing. + */ +router.setRoutes({}) +` + const result = transformFileRoutes(code) + assert.equal(result, null) + }) + + it('returns null for block comment line (leading *)', () => { + const code = ` * router.setPath('./pages')\n` + const result = transformFileRoutes(code) + assert.equal(result, null) + }) +}) + +describe('transformFileRoutes – basic transformation', () => { + it('transforms router.setPath with double-quoted path', () => { + const code = `router.setPath("./pages")` + const result = transformFileRoutes(code) + assert.ok(result, 'should return a result') + assert.match(result!.code, /__geaBuildFileRoutes/, 'should use the alias') + assert.match(result!.code, /\.setRoutes\(/, 'should call .setRoutes(') + }) + + it('transforms router.setPath with single-quoted path', () => { + const code = `router.setPath('./pages')` + const result = transformFileRoutes(code) + assert.ok(result) + assert.match(result!.code, /\.setRoutes\(/) + }) + + it('emits page glob with the correct pattern', () => { + const code = `router.setPath('./pages')` + const result = transformFileRoutes(code)! + assert.match(result.code, /import\.meta\.glob\("\.\/pages\/\*\*\/page\.\{tsx,ts,jsx,js\}"\)/) + }) + + it('emits layout glob with eager: true', () => { + const code = `router.setPath('./pages')` + const result = transformFileRoutes(code)! + assert.match(result.code, /import\.meta\.glob\("\.\/pages\/\*\*\/layout\.\{tsx,ts,jsx,js\}",\s*\{\s*eager:\s*true\s*\}\)/) + }) + + it('passes basePath as a string literal', () => { + const code = `router.setPath('./pages')` + const result = transformFileRoutes(code)! + assert.match(result.code, /"\.\/pages"\)/) + }) + + it('prepends the import statement once', () => { + const code = `router.setPath('./pages')` + const result = transformFileRoutes(code)! + const count = (result.code.match(/import \{ buildFileRoutes as __geaBuildFileRoutes \}/g) ?? []).length + assert.equal(count, 1, 'import should appear exactly once') + }) + + it('import appears before the setRoutes call', () => { + const code = `router.setPath('./pages')` + const result = transformFileRoutes(code)! + const importIdx = result.code.indexOf(IMPORT_STMT) + const setRoutesIdx = result.code.indexOf('.setRoutes(') + assert.ok(importIdx < setRoutesIdx, 'import should come before .setRoutes(') + }) + + it('map is null', () => { + const code = `router.setPath('./src/pages')` + const result = transformFileRoutes(code)! + assert.equal(result.map, null) + }) +}) + +describe('transformFileRoutes – path variations', () => { + it('handles non-default base directory', () => { + const code = `router.setPath('./src/pages')` + const result = transformFileRoutes(code)! + assert.match(result.code, /\.\/src\/pages\/\*\*\/page/) + assert.match(result.code, /"\.\/src\/pages"/) + }) + + it('handles parent-relative path (../)', () => { + const code = `router.setPath('../app/pages')` + const result = transformFileRoutes(code)! + assert.match(result.code, /\.\.\/app\/pages\/\*\*\/page/) + assert.match(result.code, /"\.\.\/app\/pages"/) + }) + + it('handles deeply nested path', () => { + const code = `router.setPath('./a/b/c')` + const result = transformFileRoutes(code)! + assert.match(result.code, /\.\/a\/b\/c\/\*\*\/page/) + }) +}) + +describe('transformFileRoutes – HMR / deduplication', () => { + it('does not double-prepend import on repeated calls (simulated HMR)', () => { + const code = `router.setPath('./pages')` + const first = transformFileRoutes(code)!.code + // Simulate HMR: the already-transformed code is passed in again + const second = transformFileRoutes(first) + // Already has __geaBuildFileRoutes but no .setPath( anymore → null + assert.equal(second, null, 'second pass with no more .setPath( should be a no-op') + }) + + it('does not duplicate import when user already has it in code', () => { + const code = + `import { buildFileRoutes as __geaBuildFileRoutes } from '@geajs/core';\n` + + `router.setPath('./pages')` + const result = transformFileRoutes(code)! + const count = (result.code.match(/import \{ buildFileRoutes as __geaBuildFileRoutes \}/g) ?? []).length + assert.equal(count, 1, 'import must not be duplicated when already present') + }) +}) + +describe('transformFileRoutes – multiple setPath calls', () => { + it('transforms all setPath calls in one pass', () => { + const code = [ + `router.setPath('./pages')`, + `adminRouter.setPath('./admin/pages')`, + ].join('\n') + const result = transformFileRoutes(code)! + // Both should be replaced + assert.doesNotMatch(result.code, /\.setPath\(/, 'no .setPath( should remain') + const setRoutesCount = (result.code.match(/\.setRoutes\(/g) ?? []).length + assert.equal(setRoutesCount, 2, 'both calls should become .setRoutes(') + }) + + it('import is still added only once for multiple calls', () => { + const code = [ + `router.setPath('./pages')`, + `adminRouter.setPath('./admin/pages')`, + ].join('\n') + const result = transformFileRoutes(code)! + const count = (result.code.match(/import \{ buildFileRoutes as __geaBuildFileRoutes \}/g) ?? []).length + assert.equal(count, 1) + }) +}) diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 4579576..d60d4e9 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -47,6 +47,7 @@ const examples: ExampleDef[] = [ { name: 'runtime-only', port: 5309 }, { name: 'runtime-only-jsx', port: 5310 }, { name: 'ssr-router-simple', port: 5311, dir: 'ssr/router-simple' }, + { name: 'router-file-based', port: 5312, dir: 'router-file-based' }, ] const activeExamples = targetProject ? examples.filter((e) => e.name === targetProject) : examples diff --git a/tests/e2e/router-file-based.spec.ts b/tests/e2e/router-file-based.spec.ts new file mode 100644 index 0000000..9b906e6 --- /dev/null +++ b/tests/e2e/router-file-based.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test' + +test.describe('router-file-based: file-system routing via router.setPath()', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await expect(page.locator('.app')).toBeVisible() + await expect(page.locator('.nav')).toBeVisible() + }) + + test('initial render shows Home page with nav links', async ({ page }) => { + await expect(page.locator('.view h1')).toHaveText('Home') + const links = page.locator('.nav a') + await expect(links).toHaveCount(4) + await expect(page.locator('.nav a.active')).toHaveText('Home') + }) + + test('About page renders via file-based route', async ({ page }) => { + await page.locator('.nav a', { hasText: 'About' }).click() + await expect(page.locator('.view h1')).toHaveText('About') + await expect(page.locator('.nav a.active')).toHaveText('About') + }) + + test('Blog listing page renders and shows posts', async ({ page }) => { + await page.locator('.nav a', { hasText: 'Blog' }).click() + await expect(page.locator('.view h1')).toHaveText('Blog') + await expect(page.locator('.post-card')).toHaveCount(3) + }) + + test('Blog post detail renders via dynamic [slug] route', async ({ page }) => { + await page.locator('.nav a', { hasText: 'Blog' }).click() + await page.locator('.post-card', { hasText: 'File-Based Routing' }).click() + await expect(page.locator('.view h1')).toHaveText('File-Based Routing in Gea') + await expect(page.locator('.back-link')).toBeVisible() + }) + + test('back link from blog post returns to blog listing', async ({ page }) => { + await page.goto('/blog/getting-started') + await expect(page.locator('.view h1')).toHaveText('Getting Started with Gea') + await page.locator('.back-link').click() + await expect(page.locator('.view h1')).toHaveText('Blog') + }) + + test('Users listing shows all users', async ({ page }) => { + await page.locator('.nav a', { hasText: 'Users' }).click() + await expect(page.locator('.view h1')).toHaveText('Users') + await expect(page.locator('.user-row')).toHaveCount(4) + }) + + test('User profile renders via dynamic [id] route', async ({ page }) => { + await page.goto('/users/1') + await expect(page.locator('.user-profile h1')).toHaveText('Alice') + await expect(page.locator('.role-badge')).toHaveText('Engineer') + await expect(page.locator('.avatar')).toHaveText('A') + }) + + test('clicking user row navigates to profile', async ({ page }) => { + await page.locator('.nav a', { hasText: 'Users' }).click() + await page.locator('.user-row', { hasText: 'Bob' }).click() + await expect(page.locator('.user-profile h1')).toHaveText('Bob') + await expect(page.locator('.role-badge')).toHaveText('Designer') + }) + + test('catch-all [...all] route renders 404 for unknown paths', async ({ page }) => { + await page.goto('/does-not-exist') + await expect(page.locator('.not-found')).toBeVisible() + await expect(page.locator('.not-found h1')).toHaveText('404') + }) + + test('404 page shows the unmatched path', async ({ page }) => { + await page.goto('/no/such/route') + await expect(page.locator('.not-found code')).toContainText('/no/such/route') + }) + + test('Go Home link on 404 page navigates back to /', async ({ page }) => { + await page.goto('/oops') + await page.locator('.not-found a').click() + await expect(page.locator('.view h1')).toHaveText('Home') + }) + + test('root layout persists across page navigations', async ({ page }) => { + await page.locator('.app').evaluate((el) => el.setAttribute('data-layout-marker', 'root')) + + await page.locator('.nav a', { hasText: 'About' }).click() + await expect(page.locator('.view h1')).toHaveText('About') + await expect(page.locator('[data-layout-marker="root"]')).toHaveCount(1) + + await page.locator('.nav a', { hasText: 'Blog' }).click() + await expect(page.locator('.view h1')).toHaveText('Blog') + await expect(page.locator('[data-layout-marker="root"]')).toHaveCount(1) + }) + + test('direct URL navigation to nested route renders correct page', async ({ page }) => { + await page.goto('/users/3') + await expect(page.locator('.user-profile h1')).toHaveText('Charlie') + await expect(page.locator('.role-badge')).toHaveText('PM') + await expect(page.locator('.nav')).toBeVisible() + }) + + test('nav active class tracks current route correctly', async ({ page }) => { + // On /, only Home is active + await expect(page.locator('.nav a.active')).toHaveCount(1) + await expect(page.locator('.nav a.active')).toHaveText('Home') + + // On /blog, Blog is active + await page.goto('/blog') + await expect(page.locator('.nav a.active')).toHaveText('Blog') + + // On /blog/getting-started, Blog is still active (isActive prefix match) + await page.goto('/blog/getting-started') + await expect(page.locator('.nav a.active')).toHaveText('Blog') + + // On /users/2, Users is active + await page.goto('/users/2') + await expect(page.locator('.nav a.active')).toHaveText('Users') + }) +})