jac-client 0.2.12__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/cli.jac +3 -3
- jac_client/plugin/client_runtime.cl.jac +7 -4
- jac_client/plugin/impl/client_runtime.impl.jac +29 -7
- jac_client/plugin/plugin_config.jac +4 -11
- jac_client/plugin/src/compiler.jac +19 -1
- jac_client/plugin/src/config_loader.jac +1 -0
- jac_client/plugin/src/impl/compiler.impl.jac +232 -62
- jac_client/plugin/src/impl/config_loader.impl.jac +8 -0
- 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 +54 -15
- jac_client/plugin/src/route_scanner.jac +44 -0
- jac_client/plugin/src/targets/desktop/sidecar/main.py +42 -23
- jac_client/plugin/src/targets/desktop_target.jac +4 -2
- jac_client/plugin/src/targets/impl/desktop_target.impl.jac +324 -112
- jac_client/plugin/src/vite_bundler.jac +18 -3
- 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_cli.py +74 -0
- jac_client/tests/test_desktop_api_url.py +854 -0
- jac_client/tests/test_e2e.py +31 -40
- jac_client/tests/test_it.py +209 -11
- {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/METADATA +2 -2
- {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/RECORD +49 -44
- 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.12.dist-info → jac_client-0.2.14.dist-info}/WHEEL +0 -0
- {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/entry_points.txt +0 -0
- {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
@@ -216,6 +222,19 @@ impl ViteBundler._format_plugin_options(self: ViteBundler, options: dict) -> str
|
|
|
216
222
|
return '{ ' + ', '.join(items) + ' }';
|
|
217
223
|
}
|
|
218
224
|
|
|
225
|
+
"""Resolve the API base URL using a consistent priority chain.
|
|
226
|
+
|
|
227
|
+
Priority: jac.toml base_url > direct override > env var (desktop start) > "" (same-origin)
|
|
228
|
+
"""
|
|
229
|
+
impl ViteBundler._resolve_api_base_url(
|
|
230
|
+
self: ViteBundler, api_base_url_override: str = ""
|
|
231
|
+
) -> str {
|
|
232
|
+
api_config_data = self.config_loader.get_api_config();
|
|
233
|
+
toml_base_url = api_config_data.get('base_url', '');
|
|
234
|
+
env_override = os.environ.get(API_BASE_URL_ENV_VAR, '');
|
|
235
|
+
return toml_base_url or api_base_url_override or env_override;
|
|
236
|
+
}
|
|
237
|
+
|
|
219
238
|
"""Get a valid JavaScript variable name from plugin module name."""
|
|
220
239
|
impl ViteBundler._get_plugin_var_name(self: ViteBundler, plugin_name: str) -> str {
|
|
221
240
|
name = plugin_name.split('/')[-1];
|
|
@@ -224,13 +243,20 @@ impl ViteBundler._get_plugin_var_name(self: ViteBundler, plugin_name: str) -> st
|
|
|
224
243
|
return name;
|
|
225
244
|
}
|
|
226
245
|
|
|
227
|
-
"""Create vite.config.js from config.json during bundling.
|
|
228
|
-
|
|
246
|
+
"""Create vite.config.js from config.json during bundling.
|
|
247
|
+
|
|
248
|
+
api_base_url_override: overrides jac.toml base_url for desktop targets
|
|
249
|
+
where the CLI knows the backend port at startup time.
|
|
250
|
+
"""
|
|
251
|
+
impl ViteBundler.create_vite_config(
|
|
252
|
+
self: ViteBundler, entry_file: Path, api_base_url_override: str = ""
|
|
253
|
+
) -> Path {
|
|
229
254
|
build_dir = self._get_client_dir();
|
|
230
255
|
build_dir.mkdir(parents=True, exist_ok=True);
|
|
231
256
|
configs_dir = build_dir / 'configs';
|
|
232
257
|
configs_dir.mkdir(exist_ok=True);
|
|
233
258
|
vite_config_data = self.config_loader.get_vite_config();
|
|
259
|
+
api_base_url = self._resolve_api_base_url(api_base_url_override);
|
|
234
260
|
config_path = configs_dir / 'vite.config.js';
|
|
235
261
|
# TypeScript is always enabled by default
|
|
236
262
|
try {
|
|
@@ -355,6 +381,9 @@ function jacSourceMapper() {{
|
|
|
355
381
|
*/
|
|
356
382
|
|
|
357
383
|
export default defineConfig({{
|
|
384
|
+
define: {{
|
|
385
|
+
'globalThis.__JAC_API_BASE_URL__': '"{api_base_url}"',
|
|
386
|
+
}},
|
|
358
387
|
plugins: [
|
|
359
388
|
jacSourceMapper(),{(newline + plugins_str + newline + ' ') if plugins_str else ''}],
|
|
360
389
|
root: buildDir, // base folder (.jac/client/) so vite can find node_modules
|
|
@@ -459,7 +488,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
|
|
|
459
488
|
}
|
|
460
489
|
try {
|
|
461
490
|
# Install to .jac/client/node_modules with progress feedback
|
|
462
|
-
print("\n ⏳ Installing dependencies...\n"
|
|
491
|
+
console.print("\n ⏳ Installing dependencies...\n");
|
|
463
492
|
start_time = time.time();
|
|
464
493
|
result = subprocess.run(
|
|
465
494
|
['bun', 'install'],
|
|
@@ -474,7 +503,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
|
|
|
474
503
|
error_msg = f"Dependency installation failed after {elapsed:.1f}s\n\n{error_output}\nCommand: bun install";
|
|
475
504
|
raise ClientBundleError(error_msg) from None ;
|
|
476
505
|
}
|
|
477
|
-
print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)"
|
|
506
|
+
console.print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)");
|
|
478
507
|
} except FileNotFoundError {
|
|
479
508
|
# This shouldn't happen since we check for bun at the start
|
|
480
509
|
raise ClientBundleError(
|
|
@@ -507,7 +536,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
|
|
|
507
536
|
command = ['bun', 'run', 'build'];
|
|
508
537
|
}
|
|
509
538
|
# Run vite from client build directory so it can find node_modules
|
|
510
|
-
print("\n ⏳ Building client bundle...\n"
|
|
539
|
+
console.print("\n ⏳ Building client bundle...\n");
|
|
511
540
|
start_time = time.time();
|
|
512
541
|
result = subprocess.run(
|
|
513
542
|
command, cwd=build_dir, check=False, text=True, capture_output=True
|
|
@@ -520,7 +549,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
|
|
|
520
549
|
)}";
|
|
521
550
|
raise ClientBundleError(error_msg) from None ;
|
|
522
551
|
}
|
|
523
|
-
print(f"\n ✔ Client bundle built ({elapsed:.1f}s)"
|
|
552
|
+
console.print(f"\n ✔ Client bundle built ({elapsed:.1f}s)");
|
|
524
553
|
} finally {
|
|
525
554
|
# Clean up temporary package.json in client build dir
|
|
526
555
|
build_package_json = build_dir / 'package.json';
|
|
@@ -555,15 +584,23 @@ impl ViteBundler.init(
|
|
|
555
584
|
self.output_dir = output_dir or (self._get_client_dir() / 'dist');
|
|
556
585
|
}
|
|
557
586
|
|
|
558
|
-
"""Create a dev-mode vite config with API proxy for HMR.
|
|
587
|
+
"""Create a dev-mode vite config with API proxy for HMR.
|
|
588
|
+
|
|
589
|
+
api_base_url_override: when set, API calls go directly to this URL
|
|
590
|
+
instead of via proxy (used by desktop targets).
|
|
591
|
+
"""
|
|
559
592
|
impl ViteBundler.create_dev_vite_config(
|
|
560
|
-
self: ViteBundler,
|
|
593
|
+
self: ViteBundler,
|
|
594
|
+
entry_file: Path,
|
|
595
|
+
api_port: int = 8000,
|
|
596
|
+
api_base_url_override: str = ""
|
|
561
597
|
) -> Path {
|
|
562
598
|
build_dir = self._get_client_dir();
|
|
563
599
|
build_dir.mkdir(parents=True, exist_ok=True);
|
|
564
600
|
configs_dir = build_dir / 'configs';
|
|
565
601
|
configs_dir.mkdir(exist_ok=True);
|
|
566
602
|
config_path = configs_dir / 'vite.dev.config.js';
|
|
603
|
+
api_base_url = self._resolve_api_base_url(api_base_url_override);
|
|
567
604
|
# Get entry file relative path
|
|
568
605
|
try {
|
|
569
606
|
entry_relative = entry_file.relative_to(build_dir).as_posix();
|
|
@@ -599,9 +636,13 @@ const projectRoot = path.resolve(__dirname, "../../..");
|
|
|
599
636
|
* Proxies API routes to Python server at localhost:{api_port}
|
|
600
637
|
*/
|
|
601
638
|
export default defineConfig({{
|
|
639
|
+
define: {{
|
|
640
|
+
'globalThis.__JAC_API_BASE_URL__': '"{api_base_url}"',
|
|
641
|
+
}},
|
|
602
642
|
plugins: [react()],
|
|
603
643
|
root: buildDir,
|
|
604
644
|
publicDir: false,
|
|
645
|
+
appType: 'spa',
|
|
605
646
|
build: {{
|
|
606
647
|
sourcemap: true, // Enable source maps for better error messages
|
|
607
648
|
}},
|
|
@@ -731,19 +772,17 @@ impl ViteBundler.start_dev_server(self: ViteBundler, port: int = 3000) -> Any {
|
|
|
731
772
|
shutil.copy2(generated_package_json, build_package_json);
|
|
732
773
|
}
|
|
733
774
|
try {
|
|
734
|
-
print("\n ⏳ Installing dependencies...\n"
|
|
775
|
+
console.print("\n ⏳ Installing dependencies...\n");
|
|
735
776
|
start_time = time.time();
|
|
736
777
|
result = subprocess.run(
|
|
737
778
|
['bun', 'install'], cwd=build_dir, check=False, text=True
|
|
738
779
|
);
|
|
739
780
|
elapsed = time.time() - start_time;
|
|
740
781
|
if result.returncode != 0 {
|
|
741
|
-
|
|
742
|
-
f"\n ✖ bun install failed after {elapsed:.1f}s", file=sys.stderr
|
|
743
|
-
);
|
|
782
|
+
console.error(f"\n ✖ bun install failed after {elapsed:.1f}s");
|
|
744
783
|
raise ClientBundleError("Failed to install dependencies") ;
|
|
745
784
|
}
|
|
746
|
-
print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)"
|
|
785
|
+
console.print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)");
|
|
747
786
|
} finally {
|
|
748
787
|
# Clean up temp package.json
|
|
749
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
|
+
}
|
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
|
|
12
12
|
Options:
|
|
13
13
|
--module-path PATH Path to the .jac module file (default: main.jac)
|
|
14
|
-
--port PORT Port to bind the API server (default: 8000)
|
|
14
|
+
--port PORT Port to bind the API server (default: 8000, 0 = auto)
|
|
15
15
|
--base-path PATH Base path for the project (default: current directory)
|
|
16
16
|
--host HOST Host to bind to (default: 127.0.0.1)
|
|
17
17
|
--help Show this help message
|
|
@@ -20,10 +20,16 @@ Options:
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
22
|
import argparse
|
|
23
|
+
import socket
|
|
23
24
|
import sys
|
|
24
25
|
from pathlib import Path
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
|
|
28
|
+
def _find_free_port(host: str = "127.0.0.1") -> int:
|
|
29
|
+
"""Find and return a free port on the given host."""
|
|
30
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
31
|
+
s.bind((host, 0))
|
|
32
|
+
return s.getsockname()[1]
|
|
27
33
|
|
|
28
34
|
|
|
29
35
|
def main():
|
|
@@ -41,7 +47,7 @@ def main():
|
|
|
41
47
|
"--port",
|
|
42
48
|
type=int,
|
|
43
49
|
default=8000,
|
|
44
|
-
help="Port to bind the API server (default: 8000)",
|
|
50
|
+
help="Port to bind the API server (default: 8000, 0 = auto-assign free port)",
|
|
45
51
|
)
|
|
46
52
|
parser.add_argument(
|
|
47
53
|
"--base-path",
|
|
@@ -58,6 +64,10 @@ def main():
|
|
|
58
64
|
|
|
59
65
|
args = parser.parse_args()
|
|
60
66
|
|
|
67
|
+
port = args.port
|
|
68
|
+
if port == 0:
|
|
69
|
+
port = _find_free_port(args.host)
|
|
70
|
+
|
|
61
71
|
# Determine base path
|
|
62
72
|
if args.base_path:
|
|
63
73
|
base_path = Path(args.base_path).resolve()
|
|
@@ -75,8 +85,9 @@ def main():
|
|
|
75
85
|
module_path = base_path / module_path
|
|
76
86
|
|
|
77
87
|
if not module_path.exists():
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
# Console not yet available (jaclang not imported)
|
|
89
|
+
sys.stderr.write(f"Error: Module file not found: {module_path}\n")
|
|
90
|
+
sys.stderr.write(f" Base path: {base_path}\n")
|
|
80
91
|
sys.exit(1)
|
|
81
92
|
|
|
82
93
|
# Extract module name (without .jac extension)
|
|
@@ -88,25 +99,25 @@ def main():
|
|
|
88
99
|
# Import jaclang (must be installed via pip)
|
|
89
100
|
from jaclang.pycore.runtime import JacRuntime as Jac
|
|
90
101
|
except ImportError as e:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
102
|
+
# Console not available (jaclang import failed)
|
|
103
|
+
sys.stderr.write(f"Error: Failed to import Jac runtime: {e}\n")
|
|
104
|
+
sys.stderr.write(" Make sure jaclang is installed: pip install jaclang\n")
|
|
95
105
|
sys.exit(1)
|
|
96
106
|
|
|
107
|
+
# Get the console now that jaclang is available
|
|
108
|
+
from jaclang.cli.console import console
|
|
109
|
+
|
|
97
110
|
# Initialize Jac runtime
|
|
98
111
|
try:
|
|
99
112
|
# Import the module
|
|
100
113
|
Jac.jac_import(target=module_name, base_path=str(module_base), lng="jac")
|
|
101
114
|
if Jac.program.errors_had:
|
|
102
|
-
console.
|
|
115
|
+
console.error("Failed to compile module:")
|
|
103
116
|
for error in Jac.program.errors_had:
|
|
104
|
-
console.print(f" {error}",
|
|
117
|
+
console.print(f" {error}", style="error")
|
|
105
118
|
sys.exit(1)
|
|
106
119
|
except Exception as e:
|
|
107
|
-
console.
|
|
108
|
-
f"Error: Failed to load module '{module_name}': {e}", file=sys.stderr
|
|
109
|
-
)
|
|
120
|
+
console.error(f"Failed to load module '{module_name}': {e}")
|
|
110
121
|
import traceback
|
|
111
122
|
|
|
112
123
|
traceback.print_exc()
|
|
@@ -117,23 +128,31 @@ def main():
|
|
|
117
128
|
# Get server class (allows plugins like jac-scale to provide enhanced server)
|
|
118
129
|
server_class = Jac.get_api_server_class()
|
|
119
130
|
server = server_class(
|
|
120
|
-
module_name=module_name, port=
|
|
131
|
+
module_name=module_name, port=port, base_path=str(base_path)
|
|
121
132
|
)
|
|
122
133
|
|
|
123
|
-
|
|
124
|
-
console.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
# MUST be raw stdout — Tauri host reads this line to discover the port.
|
|
135
|
+
# Cannot use console here; Tauri parses this exact format.
|
|
136
|
+
sys.stdout.write(f"JAC_SIDECAR_PORT={port}\n")
|
|
137
|
+
sys.stdout.flush()
|
|
138
|
+
|
|
139
|
+
# stderr: Tauri drops the stdout pipe after reading the port marker,
|
|
140
|
+
# so any further stdout writes raise BrokenPipeError.
|
|
141
|
+
console.print("Jac Sidecar starting...", style="bold")
|
|
142
|
+
console.print(f" Module: {module_name}", style="muted")
|
|
143
|
+
console.print(f" Base path: {base_path}", style="muted")
|
|
144
|
+
console.print(f" Server: http://{args.host}:{port}", style="muted")
|
|
145
|
+
console.print("")
|
|
128
146
|
|
|
129
147
|
# Start the server (blocks until interrupted)
|
|
130
|
-
|
|
148
|
+
# no_client=True: client bundle is already embedded in the Tauri webview
|
|
149
|
+
server.start(dev=False, no_client=True)
|
|
131
150
|
|
|
132
151
|
except KeyboardInterrupt:
|
|
133
|
-
console.print("\nShutting down sidecar...")
|
|
152
|
+
console.print("\nShutting down sidecar...", style="muted")
|
|
134
153
|
sys.exit(0)
|
|
135
154
|
except Exception as e:
|
|
136
|
-
console.
|
|
155
|
+
console.error(f"Server failed to start: {e}")
|
|
137
156
|
import traceback
|
|
138
157
|
|
|
139
158
|
traceback.print_exc()
|
|
@@ -28,10 +28,12 @@ class DesktopTarget(ClientTarget) {
|
|
|
28
28
|
) -> Path;
|
|
29
29
|
|
|
30
30
|
"""Start desktop dev server - start web dev server and launch tauri dev."""
|
|
31
|
-
override def dev(
|
|
31
|
+
override def dev(
|
|
32
|
+
self: DesktopTarget, entry_file: Path, project_dir: Path, api_port: int = 8000
|
|
33
|
+
) -> None;
|
|
32
34
|
|
|
33
35
|
"""Start desktop app - build web bundle and launch Tauri with built bundle."""
|
|
34
36
|
override def start(
|
|
35
|
-
self: DesktopTarget, entry_file: Path, project_dir: Path
|
|
37
|
+
self: DesktopTarget, entry_file: Path, project_dir: Path, api_port: int = 8000
|
|
36
38
|
) -> None;
|
|
37
39
|
}
|