jac-client 0.2.13__py3-none-any.whl → 0.2.14__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.
- jac_client/examples/all-in-one/components/Header.jac +1 -1
- jac_client/examples/all-in-one/components/ProfitOverview.jac +1 -1
- jac_client/examples/all-in-one/components/Summary.jac +1 -1
- jac_client/examples/all-in-one/components/TransactionList.jac +2 -2
- jac_client/examples/all-in-one/components/navigation.jac +3 -9
- jac_client/examples/all-in-one/context/BudgetContext.jac +1 -1
- jac_client/examples/all-in-one/main.jac +5 -386
- jac_client/examples/all-in-one/pages/(auth)/index.jac +299 -0
- jac_client/examples/all-in-one/pages/{nestedDemo.jac → (auth)/nested.jac} +3 -13
- jac_client/examples/all-in-one/pages/{loginPage.jac → (public)/login.jac} +1 -1
- jac_client/examples/all-in-one/pages/{signupPage.jac → (public)/signup.jac} +1 -1
- jac_client/examples/all-in-one/pages/{notFound.jac → [...notFound].jac} +2 -1
- jac_client/examples/all-in-one/pages/budget.jac +11 -0
- jac_client/examples/all-in-one/pages/budget_planner_ui.cl.jac +1 -1
- jac_client/examples/all-in-one/pages/features.jac +8 -0
- jac_client/examples/all-in-one/pages/features_test_ui.cl.jac +7 -7
- jac_client/examples/all-in-one/pages/{LandingPage.jac → landing.jac} +4 -9
- jac_client/examples/all-in-one/pages/layout.jac +20 -0
- jac_client/examples/nested-folders/nested-advance/src/ButtonRoot.jac +1 -1
- jac_client/examples/nested-folders/nested-advance/src/level1/ButtonSecondL.jac +1 -1
- jac_client/examples/nested-folders/nested-advance/src/level1/level2/ButtonThirdL.jac +1 -1
- jac_client/plugin/client_runtime.cl.jac +4 -2
- jac_client/plugin/impl/client_runtime.impl.jac +12 -1
- jac_client/plugin/plugin_config.jac +4 -11
- jac_client/plugin/src/compiler.jac +15 -1
- jac_client/plugin/src/impl/compiler.impl.jac +216 -23
- jac_client/plugin/src/impl/package_installer.impl.jac +3 -2
- jac_client/plugin/src/impl/route_scanner.impl.jac +201 -0
- jac_client/plugin/src/impl/vite_bundler.impl.jac +15 -11
- jac_client/plugin/src/route_scanner.jac +44 -0
- jac_client/plugin/utils/impl/bun_installer.impl.jac +16 -19
- jac_client/plugin/utils/impl/client_deps.impl.jac +12 -16
- jac_client/templates/fullstack.jacpack +3 -2
- jac_client/tests/test_e2e.py +19 -28
- {jac_client-0.2.13.dist-info → jac_client-0.2.14.dist-info}/METADATA +2 -2
- {jac_client-0.2.13.dist-info → jac_client-0.2.14.dist-info}/RECORD +39 -35
- jac_client/examples/all-in-one/pages/BudgetPlanner.jac +0 -140
- jac_client/examples/all-in-one/pages/FeaturesTest.jac +0 -157
- {jac_client-0.2.13.dist-info → jac_client-0.2.14.dist-info}/WHEEL +0 -0
- {jac_client-0.2.13.dist-info → jac_client-0.2.14.dist-info}/entry_points.txt +0 -0
- {jac_client-0.2.13.dist-info → jac_client-0.2.14.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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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"
|
|
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)"
|
|
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"
|
|
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)"
|
|
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"
|
|
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
|
-
|
|
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)"
|
|
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
|
+
}
|