jac-client 0.2.13__py3-none-any.whl → 0.2.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. jac_client/examples/all-in-one/components/Header.jac +1 -1
  2. jac_client/examples/all-in-one/components/ProfitOverview.jac +1 -1
  3. jac_client/examples/all-in-one/components/Summary.jac +1 -1
  4. jac_client/examples/all-in-one/components/TransactionList.jac +2 -2
  5. jac_client/examples/all-in-one/components/navigation.jac +3 -9
  6. jac_client/examples/all-in-one/context/BudgetContext.jac +1 -1
  7. jac_client/examples/all-in-one/main.jac +5 -386
  8. jac_client/examples/all-in-one/pages/(auth)/index.jac +299 -0
  9. jac_client/examples/all-in-one/pages/{nestedDemo.jac → (auth)/nested.jac} +3 -13
  10. jac_client/examples/all-in-one/pages/{loginPage.jac → (public)/login.jac} +1 -1
  11. jac_client/examples/all-in-one/pages/{signupPage.jac → (public)/signup.jac} +1 -1
  12. jac_client/examples/all-in-one/pages/{notFound.jac → [...notFound].jac} +2 -1
  13. jac_client/examples/all-in-one/pages/budget.jac +11 -0
  14. jac_client/examples/all-in-one/pages/budget_planner_ui.cl.jac +1 -1
  15. jac_client/examples/all-in-one/pages/features.jac +8 -0
  16. jac_client/examples/all-in-one/pages/features_test_ui.cl.jac +7 -7
  17. jac_client/examples/all-in-one/pages/{LandingPage.jac → landing.jac} +4 -9
  18. jac_client/examples/all-in-one/pages/layout.jac +20 -0
  19. jac_client/examples/nested-folders/nested-advance/src/ButtonRoot.jac +1 -1
  20. jac_client/examples/nested-folders/nested-advance/src/level1/ButtonSecondL.jac +1 -1
  21. jac_client/examples/nested-folders/nested-advance/src/level1/level2/ButtonThirdL.jac +1 -1
  22. jac_client/plugin/client_runtime.cl.jac +4 -2
  23. jac_client/plugin/impl/client_runtime.impl.jac +12 -1
  24. jac_client/plugin/plugin_config.jac +4 -11
  25. jac_client/plugin/src/compiler.jac +15 -1
  26. jac_client/plugin/src/impl/compiler.impl.jac +216 -23
  27. jac_client/plugin/src/impl/package_installer.impl.jac +3 -2
  28. jac_client/plugin/src/impl/route_scanner.impl.jac +201 -0
  29. jac_client/plugin/src/impl/vite_bundler.impl.jac +15 -11
  30. jac_client/plugin/src/route_scanner.jac +44 -0
  31. jac_client/plugin/utils/impl/bun_installer.impl.jac +16 -19
  32. jac_client/plugin/utils/impl/client_deps.impl.jac +12 -16
  33. jac_client/templates/fullstack.jacpack +3 -2
  34. jac_client/tests/test_e2e.py +19 -28
  35. jac_client/tests/test_it.py +247 -0
  36. {jac_client-0.2.13.dist-info → jac_client-0.2.15.dist-info}/METADATA +2 -2
  37. {jac_client-0.2.13.dist-info → jac_client-0.2.15.dist-info}/RECORD +40 -36
  38. jac_client/examples/all-in-one/pages/BudgetPlanner.jac +0 -140
  39. jac_client/examples/all-in-one/pages/FeaturesTest.jac +0 -157
  40. {jac_client-0.2.13.dist-info → jac_client-0.2.15.dist-info}/WHEEL +0 -0
  41. {jac_client-0.2.13.dist-info → jac_client-0.2.15.dist-info}/entry_points.txt +0 -0
  42. {jac_client-0.2.13.dist-info → jac_client-0.2.15.dist-info}/top_level.txt +0 -0
@@ -22,8 +22,16 @@ impl ViteCompiler.compile(
22
22
  );
23
23
  client_globals_map = self.jac_compiler.extract_globals(module_manifest, module);
24
24
  collected_globals: dict[(str, Any)] = dict(client_globals_map);
25
+ visited: set[Path] = set();
25
26
  self.compile_dependencies_recursively(
26
27
  module_path,
28
+ visited=visited,
29
+ collected_exports=collected_exports,
30
+ collected_globals=collected_globals
31
+ );
32
+ # Scan pages/ directory for file-based routing (compiles page files too)
33
+ self._has_pages = self._scan_and_compile_pages(
34
+ visited=visited,
27
35
  collected_exports=collected_exports,
28
36
  collected_globals=collected_globals
29
37
  );
@@ -55,12 +63,110 @@ impl ViteCompiler.compile_and_bundle(
55
63
  impl ViteCompiler.create_entry_file(self: ViteCompiler, module_path: Path) -> None {
56
64
  # Use _entry.js to avoid conflict with compiled modules that may be named main.js
57
65
  entry_file = self.compiled_dir / '_entry.js';
58
- # Derive the app module filename from the entry point (e.g., main.jac -> main.js, app.jac -> app.js)
59
- app_module_name = module_path.stem;
60
- entry_content = f'import React from "react";\nimport {{ createRoot }} from "react-dom/client";\nimport {{ app as App }} from "./{app_module_name}.js";\nimport {{ JacClientErrorBoundary, ErrorFallback }} from "@jac/runtime";\n\nconst root = createRoot(document.getElementById("root"));\nroot.render(\n\tReact.createElement(\n\t\tJacClientErrorBoundary,{{ FallbackComponent: ErrorFallback }},\n\t\tReact.createElement(App, null)\n\t)\n);\n';
66
+ if self._has_pages {
67
+ # File-based routing mode: import from generated _routes.js
68
+ entry_content = self._create_pages_entry_content(module_path);
69
+ } else {
70
+ # Explicit routing mode: import app from entry module (original behavior)
71
+ app_module_name = module_path.stem;
72
+ entry_content = f'import React from "react";\nimport {{ createRoot }} from "react-dom/client";\nimport {{ app as App }} from "./{app_module_name}.js";\nimport {{ JacClientErrorBoundary, ErrorFallback }} from "@jac/runtime";\n\nconst root = createRoot(document.getElementById("root"));\nroot.render(\n\tReact.createElement(\n\t\tJacClientErrorBoundary,{{ FallbackComponent: ErrorFallback }},\n\t\tReact.createElement(App, null)\n\t)\n);\n';
73
+ }
61
74
  entry_file.write_text(entry_content, encoding='utf-8');
62
75
  }
63
76
 
77
+ impl ViteCompiler._create_pages_entry_content(
78
+ self: ViteCompiler, module_path: Path
79
+ ) -> str {
80
+ lines: list[str] = [];
81
+ auth_redirect = "/login";
82
+ try {
83
+ import from ..config_loader { PluginConfig }
84
+ config = PluginConfig.load(self.project_dir);
85
+ if config and config?.routing and config.routing {
86
+ auth_redirect = config.routing.get('auth_redirect', '/login');
87
+ }
88
+ } except Exception { }
89
+ # Check if main.jac exists and exports app (used as wrapper)
90
+ app_module = self.compiled_dir / f"{module_path.stem}.js";
91
+ has_app_wrapper = app_module.exists();
92
+ # --- Imports ---
93
+ lines.append('import React from "react";');
94
+ lines.append('import { createRoot } from "react-dom/client";');
95
+ lines.append('import { BrowserRouter, Routes, Route } from "react-router-dom";');
96
+ lines.append(
97
+ 'import { JacClientErrorBoundary, ErrorFallback, AuthGuard } from "@jac/runtime";'
98
+ );
99
+ lines.append('import { routes, layouts } from "./_routes.js";');
100
+ if has_app_wrapper {
101
+ lines.append(f'import {{ app as AppWrapper }} from "./{module_path.stem}.js";');
102
+ }
103
+ lines.append('');
104
+ # --- Route helpers ---
105
+ lines.append('const publicRoutes = routes.filter(r => !r.auth);');
106
+ lines.append('const authRoutes = routes.filter(r => r.auth);');
107
+ lines.append('const RootLayout = layouts["/"] || null;');
108
+ lines.append('');
109
+ lines.append('function renderRoutes(routeList) {');
110
+ lines.append('\treturn routeList.map(r =>');
111
+ lines.append(
112
+ '\t\tReact.createElement(Route, { key: r.path, path: r.path, element: React.createElement(r.element, null) })'
113
+ );
114
+ lines.append('\t);');
115
+ lines.append('}');
116
+ lines.append('');
117
+ # --- Auth guard routes (reused in both layout and non-layout paths) ---
118
+ # AuthGuard is a runtime function compiled with plain parameters (not React props
119
+ # destructuring), so we wrap it in a component that passes the redirect string directly.
120
+ lines.append(
121
+ f'function _AuthGuardRoute() {{ return AuthGuard("{auth_redirect}"); }}'
122
+ );
123
+ lines.append(
124
+ 'const authGuardElement = React.createElement(_AuthGuardRoute, null);'
125
+ );
126
+ lines.append('');
127
+ # --- Single App component with one BrowserRouter ---
128
+ lines.append('function App() {');
129
+ lines.append('\tconst routeChildren = [');
130
+ lines.append('\t\t...renderRoutes(publicRoutes),');
131
+ lines.append(
132
+ '\t\tReact.createElement(Route, { key: "__auth", element: authGuardElement },'
133
+ );
134
+ lines.append('\t\t\t...renderRoutes(authRoutes)');
135
+ lines.append('\t\t),');
136
+ lines.append('\t];');
137
+ lines.append('');
138
+ lines.append('\tconst routeTree = RootLayout');
139
+ lines.append(
140
+ '\t\t? React.createElement(Route, { element: React.createElement(RootLayout, null) }, ...routeChildren)'
141
+ );
142
+ lines.append('\t\t: routeChildren;');
143
+ lines.append('');
144
+ lines.append('\treturn React.createElement(BrowserRouter, null,');
145
+ lines.append('\t\tReact.createElement(Routes, null,');
146
+ lines.append('\t\t\t...(Array.isArray(routeTree) ? routeTree : [routeTree])');
147
+ lines.append('\t\t)');
148
+ lines.append('\t);');
149
+ lines.append('}');
150
+ lines.append('');
151
+ # --- Root render ---
152
+ if has_app_wrapper {
153
+ lines.append(
154
+ 'const appElement = React.createElement(AppWrapper, null, React.createElement(App, null));'
155
+ );
156
+ } else {
157
+ lines.append('const appElement = React.createElement(App, null);');
158
+ }
159
+ lines.append('');
160
+ lines.append('const root = createRoot(document.getElementById("root"));');
161
+ lines.append('root.render(');
162
+ lines.append(
163
+ '\tReact.createElement(JacClientErrorBoundary, { FallbackComponent: ErrorFallback }, appElement)'
164
+ );
165
+ lines.append(');');
166
+ lines.append('');
167
+ return "\n".join(lines);
168
+ }
169
+
64
170
  """Copy assets from root assets/ folder to compiled/assets/ for @jac-client/assets alias."""
65
171
  impl ViteCompiler.copy_root_assets(self: ViteCompiler) -> None {
66
172
  root_assets_dir = self.project_dir / 'assets';
@@ -171,28 +277,10 @@ impl ViteCompiler.compile_dependencies_recursively(
171
277
  combined_js = self.jac_compiler.add_runtime_imports(module_js);
172
278
  try {
173
279
  relative_path = module_path.relative_to(source_root);
174
- # Handle compound extensions like .cl.jac, .impl.jac -> .js
175
- rel_str = str(relative_path);
176
- for compound_ext in ['.cl.jac', '.impl.jac', '.test.jac'] {
177
- if rel_str.endswith(compound_ext) {
178
- rel_str = rel_str[:-len(compound_ext)] + '.js';
179
- break;
180
- }
181
- } else {
182
- rel_str = str(relative_path.with_suffix('.js'));
183
- }
280
+ rel_str = self._jac_path_to_js(str(relative_path));
184
281
  output_path = self.compiled_dir / rel_str;
185
282
  } except ValueError {
186
- # Handle compound extensions in filename
187
- name = module_path.name;
188
- for compound_ext in ['.cl.jac', '.impl.jac', '.test.jac'] {
189
- if name.endswith(compound_ext) {
190
- name = name[:-len(compound_ext)] + '.js';
191
- break;
192
- }
193
- } else {
194
- name = module_path.stem + '.js';
195
- }
283
+ name = self._jac_path_to_js(module_path.name);
196
284
  output_path = self.compiled_dir / name;
197
285
  }
198
286
  output_path.parent.mkdir(parents=True, exist_ok=True);
@@ -247,6 +335,109 @@ impl ViteCompiler.compile_runtime_utils(self: ViteCompiler) -> tuple[str, list[s
247
335
  return (runtimeutils_js, all_exports);
248
336
  }
249
337
 
338
+ """Convert a .jac relative path string to .js, handling compound extensions."""
339
+ impl ViteCompiler._jac_path_to_js(self: ViteCompiler, rel_str: str) -> str {
340
+ for compound_ext in self.COMPOUND_EXTENSIONS {
341
+ if rel_str.endswith(compound_ext) {
342
+ return rel_str[:-len(compound_ext)] + '.js';
343
+ }
344
+ }
345
+ return str(Path(rel_str).with_suffix('.js'));
346
+ }
347
+
348
+ """Scan pages/ directory, compile page files, and generate _routes.js manifest.
349
+ Returns True if pages/ exists and routes were generated."""
350
+ impl ViteCompiler._scan_and_compile_pages(
351
+ self: ViteCompiler,
352
+ visited: (set[Path] | None) = None,
353
+ collected_exports: (set[str] | None) = None,
354
+ collected_globals: (dict[(str, Any)] | None) = None
355
+ ) -> bool {
356
+ scanner = RouteScanner(self.project_dir);
357
+ if not scanner.has_pages_dir() {
358
+ return False;
359
+ }
360
+ routes = scanner.scan();
361
+ if not routes and not scanner.get_layouts() {
362
+ return False;
363
+ }
364
+ self._route_scanner = scanner;
365
+ # Compile each page file through the standard pipeline
366
+ source_root = self.project_dir;
367
+ for page_file in scanner.get_page_files() {
368
+ self.compile_dependencies_recursively(
369
+ page_file,
370
+ visited=visited,
371
+ collected_exports=collected_exports,
372
+ collected_globals=collected_globals,
373
+ source_root=source_root
374
+ );
375
+ }
376
+ self._generate_routes_manifest();
377
+ return True;
378
+ }
379
+
380
+ """Generate _routes.js manifest from previously scanned route entries."""
381
+ impl ViteCompiler._generate_routes_manifest(self: ViteCompiler) -> None {
382
+ scanner = self._route_scanner;
383
+ # Use cached results from _scan_and_compile_pages — don't re-scan
384
+ routes = scanner._routes;
385
+ layouts = scanner.get_layouts();
386
+ lines: list[str] = [];
387
+ # Import page components
388
+ for route in routes {
389
+ if route.is_layout {
390
+ continue;
391
+ }
392
+ # Compute relative import path from pages/ to compiled/pages/
393
+ try {
394
+ rel_path = route.file_path.relative_to(self.project_dir);
395
+ } except ValueError {
396
+ rel_path = route.file_path.relative_to(scanner.pages_dir.parent);
397
+ }
398
+ rel_str = self._jac_path_to_js(str(rel_path));
399
+ lines.append(
400
+ f'import {{ page as {route.component_import} }} from "./{rel_str}";'
401
+ );
402
+ }
403
+ # Import layout components
404
+ for (prefix, layout) in layouts.items() {
405
+ try {
406
+ rel_path = layout.file_path.relative_to(self.project_dir);
407
+ } except ValueError {
408
+ rel_path = layout.file_path.relative_to(scanner.pages_dir.parent);
409
+ }
410
+ rel_str = self._jac_path_to_js(str(rel_path));
411
+ lines.append(
412
+ f'import {{ layout as {layout.component_import} }} from "./{rel_str}";'
413
+ );
414
+ }
415
+ lines.append('');
416
+ # Export routes array
417
+ lines.append('export const routes = [');
418
+ for route in routes {
419
+ if route.is_layout {
420
+ continue;
421
+ }
422
+ auth_str = "true" if route.auth_required else "false";
423
+ lines.append(
424
+ f'\t{{ path: "{route.path}", element: {route.component_import}, auth: {auth_str} }},'
425
+ );
426
+ }
427
+ lines.append('];');
428
+ lines.append('');
429
+ # Export layouts object
430
+ lines.append('export const layouts = {');
431
+ for (prefix, layout) in layouts.items() {
432
+ lines.append(f'\t"{prefix}": {layout.component_import},');
433
+ }
434
+ lines.append('};');
435
+ lines.append('');
436
+ manifest_path = self.compiled_dir / '_routes.js';
437
+ manifest_path.parent.mkdir(parents=True, exist_ok=True);
438
+ manifest_path.write_text("\n".join(lines), encoding='utf-8');
439
+ }
440
+
250
441
  """Initialize the Vite compiler."""
251
442
  impl ViteCompiler.init(
252
443
  self: ViteCompiler,
@@ -294,4 +485,6 @@ impl ViteCompiler.init(
294
485
  self.import_processor = ImportProcessor();
295
486
  self.asset_processor = AssetProcessor();
296
487
  self.vite_bundler = ViteBundler(self.project_dir, vite_output_dir, vite_minify);
488
+ self._has_pages = False;
489
+ self._route_scanner = None;
297
490
  }
@@ -40,6 +40,7 @@ impl PackageInstaller.uninstall_package(
40
40
  """Regenerate package.json from jac.toml and run bun install."""
41
41
  impl PackageInstaller._regenerate_and_install(self: PackageInstaller) -> None {
42
42
  import from jac_client.plugin.utils { ensure_bun_available }
43
+ import from jaclang.cli.console { console }
43
44
  # Ensure bun is available before proceeding
44
45
  if not ensure_bun_available() {
45
46
  raise ClientBundleError('Bun is required. Install manually: https://bun.sh') from None ;
@@ -51,14 +52,14 @@ impl PackageInstaller._regenerate_and_install(self: PackageInstaller) -> None {
51
52
  bundler._ensure_root_package_json();
52
53
  try {
53
54
  # Run bun install to actually install the packages
54
- print("\n ⏳ Installing packages...\n", flush=True);
55
+ console.print("\n ⏳ Installing packages...\n");
55
56
  result = subprocess.run(
56
57
  ['bun', 'install'], cwd=self.project_dir, check=False, text=True
57
58
  );
58
59
  if result.returncode != 0 {
59
60
  raise ClientBundleError('Failed to install packages (see output above)') ;
60
61
  }
61
- print("\n ✔ Packages installed", flush=True);
62
+ console.print("\n ✔ Packages installed");
62
63
  } finally {
63
64
  # Always clean up root package.json and move bun.lockb
64
65
  bundler._cleanup_root_package_files();
@@ -0,0 +1,201 @@
1
+ import re;
2
+ import from pathlib { Path }
3
+ import from operator { attrgetter }
4
+ import from jaclang.runtimelib.client_bundle { ClientBundleError }
5
+
6
+ impl RouteEntry.init(
7
+ self: RouteEntry,
8
+ path: str,
9
+ component_import: str,
10
+ file_path: Path,
11
+ auth_required: bool = False,
12
+ is_layout: bool = False,
13
+ is_catch_all: bool = False,
14
+ children: (list[RouteEntry] | None) = None
15
+ ) {
16
+ self.path = path;
17
+ self.component_import = component_import;
18
+ self.file_path = file_path;
19
+ self.auth_required = auth_required;
20
+ self.is_layout = is_layout;
21
+ self.is_catch_all = is_catch_all;
22
+ self.children = children if children is not None else [];
23
+ }
24
+
25
+ impl RouteScanner.init(self: RouteScanner, project_root: Path) {
26
+ self.project_root = project_root;
27
+ self.pages_dir = project_root / self.PAGES_DIR_NAME;
28
+ self._routes: list[RouteEntry] = [];
29
+ self._layouts: dict[(str, RouteEntry)] = {};
30
+ self._page_files: list[Path] = [];
31
+ }
32
+
33
+ impl RouteScanner.has_pages_dir(self: RouteScanner) -> bool {
34
+ return self.pages_dir.exists() and self.pages_dir.is_dir();
35
+ }
36
+
37
+ impl RouteScanner.scan(self: RouteScanner) -> list[RouteEntry] {
38
+ if not self.has_pages_dir() {
39
+ return [];
40
+ }
41
+ self._routes = [];
42
+ self._layouts = {};
43
+ self._page_files = [];
44
+ self._routes = self._scan_directory(self.pages_dir);
45
+ self._detect_collisions(self._routes);
46
+ return self._routes;
47
+ }
48
+
49
+ """Return layouts discovered during the last scan, keyed by URL prefix."""
50
+ impl RouteScanner.get_layouts(self: RouteScanner) -> dict[(str, RouteEntry)] {
51
+ return self._layouts;
52
+ }
53
+
54
+ impl RouteScanner.get_page_files(self: RouteScanner) -> list[Path] {
55
+ return self._page_files;
56
+ }
57
+
58
+ impl RouteScanner._scan_directory(
59
+ self: RouteScanner,
60
+ dir_path: Path,
61
+ url_prefix: str = "",
62
+ auth_required: bool = False
63
+ ) -> list[RouteEntry] {
64
+ routes: list[RouteEntry] = [];
65
+ # Collect and sort entries for deterministic route ordering
66
+ entries = sorted(dir_path.iterdir(), key=attrgetter('name'));
67
+ for entry in entries {
68
+ if entry.is_dir() {
69
+ dir_name = entry.name;
70
+ # Route groups: (public), (auth), etc.
71
+ group_match = re.match(r'^\((\w+)\)$', dir_name);
72
+ if group_match {
73
+ group_name = group_match.group(1);
74
+ group_auth = auth_required or (group_name == self.AUTH_GROUP_NAME);
75
+ # Route groups don't add to the URL path
76
+ child_routes = self._scan_directory(entry, url_prefix, group_auth);
77
+ routes.extend(child_routes);
78
+ } else {
79
+ # Regular subdirectory — adds to URL path
80
+ child_prefix = f"{url_prefix}/{dir_name}";
81
+ child_routes = self._scan_directory(entry, child_prefix, auth_required);
82
+ routes.extend(child_routes);
83
+ }
84
+ } elif entry.is_file() and entry.suffix == '.jac' {
85
+ if any(suffix in entry.name for suffix in self.SKIP_SUFFIXES) {
86
+ continue;
87
+ }
88
+ stem = entry.stem;
89
+ # layout.jac — not a route, it's a layout wrapper
90
+ if stem == self.LAYOUT_FILENAME {
91
+ layout_key = url_prefix or "/";
92
+ if layout_key in self._layouts {
93
+ raise ClientBundleError(
94
+ f"Layout collision: '{layout_key}' has layouts from both "
95
+ f"'{self._layouts[layout_key].file_path}' and '{entry}'. "
96
+ f"Remove one of them."
97
+ ) ;
98
+ }
99
+ component_name = self._file_to_component_name(entry);
100
+ layout_entry = RouteEntry(
101
+ path=layout_key,
102
+ component_import=component_name,
103
+ file_path=entry,
104
+ is_layout=True
105
+ );
106
+ self._layouts[layout_key] = layout_entry;
107
+ self._page_files.append(entry);
108
+ continue;
109
+ }
110
+ route_segment = self._file_to_route_path(stem);
111
+ is_catch_all = stem.startswith('[...');
112
+ if stem == self.INDEX_FILENAME {
113
+ # index.jac maps to the directory path
114
+ route_path = url_prefix or "/";
115
+ } else {
116
+ route_path = f"{url_prefix}/{route_segment}";
117
+ }
118
+ component_name = self._file_to_component_name(entry);
119
+ route = RouteEntry(
120
+ path=route_path,
121
+ component_import=component_name,
122
+ file_path=entry,
123
+ auth_required=auth_required,
124
+ is_catch_all=is_catch_all
125
+ );
126
+ routes.append(route);
127
+ self._page_files.append(entry);
128
+ }
129
+ }
130
+ return routes;
131
+ }
132
+
133
+ """Convert a filename stem to a React Router route path segment.
134
+
135
+ index → (empty, handled by caller)
136
+ about → about
137
+ [id] → :id
138
+ [...slug] → *
139
+ """
140
+ impl RouteScanner._file_to_route_path(self: RouteScanner, filename: str) -> str {
141
+ # Catch-all: [...slug] → *
142
+ catch_all_match = re.match(r'^\[\.\.\.(\w+)\]$', filename);
143
+ if catch_all_match {
144
+ return "*";
145
+ }
146
+ # Dynamic segment: [id] → :id
147
+ dynamic_match = re.match(r'^\[(\w+)\]$', filename);
148
+ if dynamic_match {
149
+ return f":{dynamic_match.group(1)}";
150
+ }
151
+ # Static segment
152
+ return filename;
153
+ }
154
+
155
+ """Generate a unique component import name from a page file path.
156
+
157
+ pages/index.jac → PagesIndex
158
+ pages/about.jac → PagesAbout
159
+ pages/(auth)/dashboard.jac → PagesDashboard
160
+ pages/users/[id].jac → PagesUsersId
161
+ pages/[...slug].jac → PagesSlug
162
+ """
163
+ impl RouteScanner._file_to_component_name(self: RouteScanner, file_path: Path) -> str {
164
+ try {
165
+ relative = file_path.relative_to(self.pages_dir);
166
+ } except ValueError {
167
+ relative = Path(file_path.name);
168
+ }
169
+ parts: list[str] = [];
170
+ for part in relative.with_suffix('').parts {
171
+ # Skip route group directories like (auth), (public)
172
+ if re.match(r'^\(\w+\)$', part) {
173
+ continue;
174
+ }
175
+ # Strip catch-all prefix: [...slug] → slug
176
+ clean = re.sub(r'^\[\.\.\.(\w+)\]$', r'\1', part);
177
+ # Strip dynamic brackets: [id] → id
178
+ clean = re.sub(r'^\[(\w+)\]$', r'\1', clean);
179
+ parts.append(clean[0].upper() + clean[1:] if len(clean) > 0 else clean);
180
+ }
181
+ return self.COMPONENT_PREFIX + "".join(parts);
182
+ }
183
+
184
+ impl RouteScanner._detect_collisions(
185
+ self: RouteScanner, routes: list[RouteEntry]
186
+ ) -> None {
187
+ seen: dict[(str, Path)] = {};
188
+ for route in routes {
189
+ if route.is_layout {
190
+ continue;
191
+ }
192
+ if route.path in seen {
193
+ raise ClientBundleError(
194
+ f"Route collision: '{route.path}' is defined by both "
195
+ f"'{seen[route.path]}' and '{route.file_path}'. "
196
+ f"Remove one of them."
197
+ ) ;
198
+ }
199
+ seen[route.path] = route.file_path;
200
+ }
201
+ }
@@ -1,11 +1,17 @@
1
+ import from jaclang.cli.console { console }
2
+
1
3
  """Get the client build directory from project config."""
2
4
  impl ViteBundler._get_client_dir(self: ViteBundler) -> Path {
3
- # Try to get from project config
5
+ # Try to get from project config, but only if it matches our project dir
4
6
  try {
5
7
  import from jaclang.project.config { get_config }
6
8
  config = get_config();
7
9
  if config is not None {
8
- return config.get_client_dir();
10
+ config_root = config.project_root;
11
+ if config_root is not None
12
+ and config_root.resolve() == self.project_dir.resolve() {
13
+ return config.get_client_dir();
14
+ }
9
15
  }
10
16
  } except ImportError { }
11
17
  # Fallback to default
@@ -482,7 +488,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
482
488
  }
483
489
  try {
484
490
  # Install to .jac/client/node_modules with progress feedback
485
- print("\n ⏳ Installing dependencies...\n", flush=True);
491
+ console.print("\n ⏳ Installing dependencies...\n");
486
492
  start_time = time.time();
487
493
  result = subprocess.run(
488
494
  ['bun', 'install'],
@@ -497,7 +503,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
497
503
  error_msg = f"Dependency installation failed after {elapsed:.1f}s\n\n{error_output}\nCommand: bun install";
498
504
  raise ClientBundleError(error_msg) from None ;
499
505
  }
500
- print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)", flush=True);
506
+ console.print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)");
501
507
  } except FileNotFoundError {
502
508
  # This shouldn't happen since we check for bun at the start
503
509
  raise ClientBundleError(
@@ -530,7 +536,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
530
536
  command = ['bun', 'run', 'build'];
531
537
  }
532
538
  # Run vite from client build directory so it can find node_modules
533
- print("\n ⏳ Building client bundle...\n", flush=True);
539
+ console.print("\n ⏳ Building client bundle...\n");
534
540
  start_time = time.time();
535
541
  result = subprocess.run(
536
542
  command, cwd=build_dir, check=False, text=True, capture_output=True
@@ -543,7 +549,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
543
549
  )}";
544
550
  raise ClientBundleError(error_msg) from None ;
545
551
  }
546
- print(f"\n ✔ Client bundle built ({elapsed:.1f}s)", flush=True);
552
+ console.print(f"\n ✔ Client bundle built ({elapsed:.1f}s)");
547
553
  } finally {
548
554
  # Clean up temporary package.json in client build dir
549
555
  build_package_json = build_dir / 'package.json';
@@ -766,19 +772,17 @@ impl ViteBundler.start_dev_server(self: ViteBundler, port: int = 3000) -> Any {
766
772
  shutil.copy2(generated_package_json, build_package_json);
767
773
  }
768
774
  try {
769
- print("\n ⏳ Installing dependencies...\n", flush=True);
775
+ console.print("\n ⏳ Installing dependencies...\n");
770
776
  start_time = time.time();
771
777
  result = subprocess.run(
772
778
  ['bun', 'install'], cwd=build_dir, check=False, text=True
773
779
  );
774
780
  elapsed = time.time() - start_time;
775
781
  if result.returncode != 0 {
776
- print(
777
- f"\n ✖ bun install failed after {elapsed:.1f}s", file=sys.stderr
778
- );
782
+ console.error(f"\n ✖ bun install failed after {elapsed:.1f}s");
779
783
  raise ClientBundleError("Failed to install dependencies") ;
780
784
  }
781
- print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)", flush=True);
785
+ console.print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)");
782
786
  } finally {
783
787
  # Clean up temp package.json
784
788
  if build_package_json.exists() {
@@ -0,0 +1,44 @@
1
+ """File-based route scanner for pages/ directory convention."""
2
+ import from pathlib { Path }
3
+
4
+ """A single route entry derived from a file in pages/."""
5
+ class RouteEntry {
6
+ def init(
7
+ self: RouteEntry,
8
+ path: str,
9
+ component_import: str,
10
+ file_path: Path,
11
+ auth_required: bool = False,
12
+ is_layout: bool = False,
13
+ is_catch_all: bool = False,
14
+ children: (list[RouteEntry] | None) = None
15
+ );
16
+ }
17
+
18
+ """Scans a pages/ directory and produces a route tree."""
19
+ class RouteScanner {
20
+ with entry {
21
+ PAGES_DIR_NAME = 'pages';
22
+ LAYOUT_FILENAME = 'layout';
23
+ INDEX_FILENAME = 'index';
24
+ AUTH_GROUP_NAME = 'auth';
25
+ COMPONENT_PREFIX = 'Pages';
26
+ SKIP_SUFFIXES = ['.cl.', '.impl.', '.test.'];
27
+ }
28
+
29
+ def init(self: RouteScanner, project_root: Path);
30
+ def has_pages_dir(self: RouteScanner) -> bool;
31
+ def scan(self: RouteScanner) -> list[RouteEntry];
32
+ def get_layouts(self: RouteScanner) -> dict[(str, RouteEntry)];
33
+ def get_page_files(self: RouteScanner) -> list[Path];
34
+ def _scan_directory(
35
+ self: RouteScanner,
36
+ dir_path: Path,
37
+ url_prefix: str = "",
38
+ auth_required: bool = False
39
+ ) -> list[RouteEntry];
40
+
41
+ def _file_to_route_path(self: RouteScanner, filename: str) -> str;
42
+ def _file_to_component_name(self: RouteScanner, file_path: Path) -> str;
43
+ def _detect_collisions(self: RouteScanner, routes: list[RouteEntry]) -> None;
44
+ }