@zenithbuild/bundler 1.3.1
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.
- package/CLAUDE.md +111 -0
- package/Cargo.lock +4244 -0
- package/Cargo.toml +65 -0
- package/README.md +135 -0
- package/build.rs +5 -0
- package/dev-server.js +117 -0
- package/index.js +18 -0
- package/package.json +14 -0
- package/src/bundler.rs +81 -0
- package/src/css.rs +266 -0
- package/src/html.rs +147 -0
- package/src/lib.rs +116 -0
- package/src/plugin.rs +332 -0
- package/src/store.rs +44 -0
- package/tests/fixtures/hello.zen +19 -0
- package/tests/integration.rs +53 -0
- package/tsconfig.json +29 -0
package/src/css.rs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
//! CSS Buffer and Pruning Module
|
|
2
|
+
//!
|
|
3
|
+
//! Handles buffering CSS from .zen files and pruning unused classes
|
|
4
|
+
//! using lightningcss with ZenManifest.css_classes as the allow-list.
|
|
5
|
+
//!
|
|
6
|
+
//! Uses AST-based pruning via lightningcss to ensure safety and correctness.
|
|
7
|
+
|
|
8
|
+
use dashmap::DashMap;
|
|
9
|
+
use lightningcss::rules::CssRule;
|
|
10
|
+
use lightningcss::selector::Component;
|
|
11
|
+
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleSheet};
|
|
12
|
+
use lightningcss::targets::Browsers;
|
|
13
|
+
use std::collections::HashSet;
|
|
14
|
+
|
|
15
|
+
/// Thread-safe CSS buffer for collecting styles from .zen files
|
|
16
|
+
#[derive(Debug)]
|
|
17
|
+
pub struct CssBuffer {
|
|
18
|
+
/// CSS content keyed by file path
|
|
19
|
+
styles: DashMap<String, String>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl CssBuffer {
|
|
23
|
+
pub fn new() -> Self {
|
|
24
|
+
Self {
|
|
25
|
+
styles: DashMap::new(),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Insert CSS content for a file
|
|
30
|
+
pub fn insert(&self, file_id: String, css: String) {
|
|
31
|
+
self.styles.insert(file_id, css);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Get all buffered CSS
|
|
35
|
+
pub fn get_all(&self) -> Vec<String> {
|
|
36
|
+
self.styles.iter().map(|r| r.value().clone()).collect()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Stitch all CSS and prune unused classes
|
|
40
|
+
///
|
|
41
|
+
/// Strategy:
|
|
42
|
+
/// 1. Parse the CSS into AST using lightningcss
|
|
43
|
+
/// 2. Walk the AST and remove rules/selectors that allow pruning
|
|
44
|
+
/// 3. Minify and print the result
|
|
45
|
+
pub fn stitch_and_prune(&self, used_classes: &[String]) -> Result<String, String> {
|
|
46
|
+
let all_css: String = self
|
|
47
|
+
.styles
|
|
48
|
+
.iter()
|
|
49
|
+
.map(|r| r.value().clone())
|
|
50
|
+
.collect::<Vec<_>>()
|
|
51
|
+
.join("\n");
|
|
52
|
+
|
|
53
|
+
if all_css.is_empty() {
|
|
54
|
+
return Ok(String::new());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build allow-list from used classes
|
|
58
|
+
let used_set: HashSet<&str> = used_classes.iter().map(|s| s.as_str()).collect();
|
|
59
|
+
|
|
60
|
+
// 1. Parse CSS
|
|
61
|
+
let mut stylesheet = StyleSheet::parse(&all_css, ParserOptions::default())
|
|
62
|
+
.map_err(|e| format!("CSS parse error: {:?}", e))?;
|
|
63
|
+
|
|
64
|
+
// 2. Prune AST (Recursive)
|
|
65
|
+
// Accessing rules directly requires ensuring we can iterate mutably
|
|
66
|
+
let rules_vec = &mut stylesheet.rules.0;
|
|
67
|
+
prune_rules(rules_vec, &used_set);
|
|
68
|
+
|
|
69
|
+
// 3. Minify and Print
|
|
70
|
+
|
|
71
|
+
stylesheet
|
|
72
|
+
.minify(MinifyOptions {
|
|
73
|
+
targets: Browsers::default().into(),
|
|
74
|
+
..Default::default()
|
|
75
|
+
})
|
|
76
|
+
.map_err(|e| format!("CSS minify error: {:?}", e))?;
|
|
77
|
+
|
|
78
|
+
let result = stylesheet
|
|
79
|
+
.to_css(PrinterOptions {
|
|
80
|
+
minify: true,
|
|
81
|
+
..Default::default()
|
|
82
|
+
})
|
|
83
|
+
.map_err(|e| format!("CSS print error: {:?}", e))?;
|
|
84
|
+
|
|
85
|
+
Ok(result.code)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Clear all buffered CSS
|
|
89
|
+
pub fn clear(&self) {
|
|
90
|
+
self.styles.clear();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
impl Default for CssBuffer {
|
|
95
|
+
fn default() -> Self {
|
|
96
|
+
Self::new()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Recursively prune CSS rules
|
|
101
|
+
///
|
|
102
|
+
/// Returns true if rule should be kept, false if it should be removed (if single rule context)
|
|
103
|
+
/// But here we operate on Vec<CssRule>, so we use retain_mut.
|
|
104
|
+
fn prune_rules(rules: &mut Vec<CssRule>, used_set: &HashSet<&str>) {
|
|
105
|
+
rules.retain_mut(|rule| {
|
|
106
|
+
match rule {
|
|
107
|
+
CssRule::Style(style_rule) => {
|
|
108
|
+
// Filter selectors in this rule
|
|
109
|
+
// style_rule.selectors is SelectorList.
|
|
110
|
+
|
|
111
|
+
// We iterate and keep selectors that are "used"
|
|
112
|
+
style_rule
|
|
113
|
+
.selectors
|
|
114
|
+
.0
|
|
115
|
+
.retain(|selector| is_selector_used(selector, used_set));
|
|
116
|
+
|
|
117
|
+
// Determine if we keep the rule:
|
|
118
|
+
// If NO selectors remain, the rule is empty and should be removed.
|
|
119
|
+
!style_rule.selectors.0.is_empty()
|
|
120
|
+
}
|
|
121
|
+
CssRule::Media(media_rule) => {
|
|
122
|
+
// Recursively prune rules inside @media
|
|
123
|
+
// media_rule.rules is CssRuleList (which wraps Vec<CssRule>).
|
|
124
|
+
// Access via .0
|
|
125
|
+
prune_rules(&mut media_rule.rules.0, used_set);
|
|
126
|
+
|
|
127
|
+
// Keep media rule only if it still has rules inside
|
|
128
|
+
!media_rule.rules.0.is_empty()
|
|
129
|
+
}
|
|
130
|
+
CssRule::Supports(supports_rule) => {
|
|
131
|
+
prune_rules(&mut supports_rule.rules.0, used_set);
|
|
132
|
+
!supports_rule.rules.0.is_empty()
|
|
133
|
+
}
|
|
134
|
+
// For other rules (Keyframes, FontFace, etc.), we keep them ALWAYS.
|
|
135
|
+
// We do not prune keyframes based on usage yet (harder analysis).
|
|
136
|
+
_ => true,
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Determine if a selector usage deems it valid to keep.
|
|
142
|
+
///
|
|
143
|
+
/// POLICY: CONSERVATIVE
|
|
144
|
+
/// - If selector has NO classes -> KEEP (Element, ID, *, etc.)
|
|
145
|
+
/// - If selector has ANY class that is in `used_set` -> KEEP.
|
|
146
|
+
/// - Only remove if ALL classes in the selector are KNOWN UNUSED.
|
|
147
|
+
fn is_selector_used(selector: &lightningcss::selector::Selector, used_set: &HashSet<&str>) -> bool {
|
|
148
|
+
let mut has_classes = false;
|
|
149
|
+
let mut any_used = false;
|
|
150
|
+
|
|
151
|
+
// Iterate over components in the selector
|
|
152
|
+
// Selector iteration yields &Component
|
|
153
|
+
for component in selector.iter() {
|
|
154
|
+
if let Component::Class(ident) = component {
|
|
155
|
+
has_classes = true;
|
|
156
|
+
// ident is Atom or similar string-like. as_ref() works for AsRef<str>.
|
|
157
|
+
if used_set.contains(ident.as_ref()) {
|
|
158
|
+
any_used = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if !has_classes {
|
|
164
|
+
// No classes involved (e.g. "div", "#app", "*"), so keep it.
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Has classes. Keep ONLY if at least one class is used.
|
|
169
|
+
any_used
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#[cfg(test)]
|
|
173
|
+
mod tests {
|
|
174
|
+
use super::*;
|
|
175
|
+
|
|
176
|
+
#[test]
|
|
177
|
+
fn test_css_buffer_insert_and_get() {
|
|
178
|
+
let buffer = CssBuffer::new();
|
|
179
|
+
buffer.insert("a.zen".into(), ".foo { color: red; }".into());
|
|
180
|
+
buffer.insert("b.zen".into(), ".bar { color: blue; }".into());
|
|
181
|
+
|
|
182
|
+
let all = buffer.get_all();
|
|
183
|
+
assert_eq!(all.len(), 2);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#[test]
|
|
187
|
+
fn test_css_stitch_and_minify() {
|
|
188
|
+
let buffer = CssBuffer::new();
|
|
189
|
+
buffer.insert("a.zen".into(), ".foo { color: red; }".into());
|
|
190
|
+
|
|
191
|
+
let result = buffer.stitch_and_prune(&["foo".into()]).unwrap();
|
|
192
|
+
assert!(result.contains("color:") || result.contains("color:red"));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#[test]
|
|
196
|
+
fn test_css_pruning_removes_unused() {
|
|
197
|
+
let buffer = CssBuffer::new();
|
|
198
|
+
buffer.insert(
|
|
199
|
+
"a.zen".into(),
|
|
200
|
+
".foo { color: red; } .bar { color: blue; } .baz { color: green; }".into(),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Only "foo" is used, "bar" and "baz" should be pruned
|
|
204
|
+
let result = buffer.stitch_and_prune(&["foo".into()]).unwrap();
|
|
205
|
+
assert!(
|
|
206
|
+
result.contains("red"),
|
|
207
|
+
"Should keep .foo (used): {}",
|
|
208
|
+
result
|
|
209
|
+
);
|
|
210
|
+
assert!(
|
|
211
|
+
!result.contains("blue"),
|
|
212
|
+
"Should prune .bar (unused): {}",
|
|
213
|
+
result
|
|
214
|
+
);
|
|
215
|
+
assert!(
|
|
216
|
+
!result.contains("green"),
|
|
217
|
+
"Should prune .baz (unused): {}",
|
|
218
|
+
result
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#[test]
|
|
223
|
+
fn test_keeps_element_selectors() {
|
|
224
|
+
let buffer = CssBuffer::new();
|
|
225
|
+
buffer.insert(
|
|
226
|
+
"a.zen".into(),
|
|
227
|
+
"body { margin: 0; } h1 { font-size: 2rem; }".into(),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Element selectors should always be kept
|
|
231
|
+
let result = buffer.stitch_and_prune(&[]).unwrap();
|
|
232
|
+
assert!(
|
|
233
|
+
result.contains("margin") || result.contains("0"),
|
|
234
|
+
"Should keep body selector: {}",
|
|
235
|
+
result
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[test]
|
|
240
|
+
fn test_keeps_id_selectors() {
|
|
241
|
+
let buffer = CssBuffer::new();
|
|
242
|
+
buffer.insert("a.zen".into(), "#app { display: flex; }".into());
|
|
243
|
+
|
|
244
|
+
// ID selectors should always be kept
|
|
245
|
+
let result = buffer.stitch_and_prune(&[]).unwrap();
|
|
246
|
+
assert!(
|
|
247
|
+
result.contains("flex"),
|
|
248
|
+
"Should keep #app selector: {}",
|
|
249
|
+
result
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn test_keeps_used_class_in_compound() {
|
|
255
|
+
let buffer = CssBuffer::new();
|
|
256
|
+
buffer.insert("a.zen".into(), ".foo.bar { color: red; }".into());
|
|
257
|
+
|
|
258
|
+
// If either class is used, keep the rule
|
|
259
|
+
let result = buffer.stitch_and_prune(&["foo".into()]).unwrap();
|
|
260
|
+
assert!(
|
|
261
|
+
result.contains("red"),
|
|
262
|
+
"Should keep .foo.bar when foo is used: {}",
|
|
263
|
+
result
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
package/src/html.rs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//! HTML Injector Module
|
|
2
|
+
//!
|
|
3
|
+
//! Injects hashed asset links into index.html:
|
|
4
|
+
//! - `<script type="module" src="...">` for the Entry Chunk
|
|
5
|
+
//! - `<link rel="stylesheet">` for the CSS Asset
|
|
6
|
+
//! - `<link rel="modulepreload">` for critical chunks
|
|
7
|
+
|
|
8
|
+
/// Asset information for HTML injection
|
|
9
|
+
#[derive(Debug, Clone)]
|
|
10
|
+
pub struct AssetInfo {
|
|
11
|
+
pub filename: String,
|
|
12
|
+
pub is_entry: bool,
|
|
13
|
+
pub is_css: bool,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// The HTML Injector that updates index.html with hashed asset paths
|
|
17
|
+
pub struct HtmlInjector {
|
|
18
|
+
/// Template HTML content
|
|
19
|
+
template: String,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl HtmlInjector {
|
|
23
|
+
pub fn new(template: String) -> Self {
|
|
24
|
+
Self { template }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Load template from file
|
|
28
|
+
pub fn from_file(path: &str) -> Result<Self, std::io::Error> {
|
|
29
|
+
let template = std::fs::read_to_string(path)?;
|
|
30
|
+
Ok(Self::new(template))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Inject asset references into the HTML template
|
|
34
|
+
pub fn inject(&self, assets: &[AssetInfo], preload_chunks: &[String]) -> String {
|
|
35
|
+
let mut html = self.template.clone();
|
|
36
|
+
|
|
37
|
+
// Build injection strings
|
|
38
|
+
let mut script_tags = String::new();
|
|
39
|
+
let mut css_links = String::new();
|
|
40
|
+
let mut preload_links = String::new();
|
|
41
|
+
|
|
42
|
+
// CSS links
|
|
43
|
+
for asset in assets.iter().filter(|a| a.is_css) {
|
|
44
|
+
css_links.push_str(&format!(
|
|
45
|
+
r#" <link rel="stylesheet" href="/{}">"#,
|
|
46
|
+
asset.filename
|
|
47
|
+
));
|
|
48
|
+
css_links.push('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Modulepreload for critical chunks
|
|
52
|
+
for chunk in preload_chunks {
|
|
53
|
+
preload_links.push_str(&format!(
|
|
54
|
+
r#" <link rel="modulepreload" href="/{}">"#,
|
|
55
|
+
chunk
|
|
56
|
+
));
|
|
57
|
+
preload_links.push('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Entry script (should be last, after preloads)
|
|
61
|
+
for asset in assets.iter().filter(|a| a.is_entry && !a.is_css) {
|
|
62
|
+
script_tags.push_str(&format!(
|
|
63
|
+
r#" <script type="module" src="/{}"></script>"#,
|
|
64
|
+
asset.filename
|
|
65
|
+
));
|
|
66
|
+
script_tags.push('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Inject before </head>
|
|
70
|
+
let head_injection = format!("{}{}", css_links, preload_links);
|
|
71
|
+
if let Some(pos) = html.find("</head>") {
|
|
72
|
+
html.insert_str(pos, &head_injection);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Inject scripts before </body>
|
|
76
|
+
if let Some(pos) = html.find("</body>") {
|
|
77
|
+
html.insert_str(pos, &script_tags);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
html
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Generate a minimal HTML template if none exists
|
|
84
|
+
pub fn generate_default(title: &str) -> String {
|
|
85
|
+
format!(
|
|
86
|
+
r#"<!DOCTYPE html>
|
|
87
|
+
<html lang="en">
|
|
88
|
+
<head>
|
|
89
|
+
<meta charset="UTF-8">
|
|
90
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
91
|
+
<title>{}</title>
|
|
92
|
+
</head>
|
|
93
|
+
<body>
|
|
94
|
+
<div id="app"></div>
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
"#,
|
|
98
|
+
title
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[cfg(test)]
|
|
104
|
+
mod tests {
|
|
105
|
+
use super::*;
|
|
106
|
+
|
|
107
|
+
#[test]
|
|
108
|
+
fn test_inject_assets() {
|
|
109
|
+
let template = r#"<!DOCTYPE html>
|
|
110
|
+
<html>
|
|
111
|
+
<head>
|
|
112
|
+
<title>Test</title>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<div id="app"></div>
|
|
116
|
+
</body>
|
|
117
|
+
</html>"#;
|
|
118
|
+
|
|
119
|
+
let injector = HtmlInjector::new(template.to_string());
|
|
120
|
+
let assets = vec![
|
|
121
|
+
AssetInfo {
|
|
122
|
+
filename: "app-x82z.js".into(),
|
|
123
|
+
is_entry: true,
|
|
124
|
+
is_css: false,
|
|
125
|
+
},
|
|
126
|
+
AssetInfo {
|
|
127
|
+
filename: "zenith-abc1.css".into(),
|
|
128
|
+
is_entry: false,
|
|
129
|
+
is_css: true,
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
let preloads = vec!["runtime-core-def2.js".into()];
|
|
133
|
+
|
|
134
|
+
let result = injector.inject(&assets, &preloads);
|
|
135
|
+
|
|
136
|
+
assert!(result.contains(r#"<link rel="stylesheet" href="/zenith-abc1.css">"#));
|
|
137
|
+
assert!(result.contains(r#"<link rel="modulepreload" href="/runtime-core-def2.js">"#));
|
|
138
|
+
assert!(result.contains(r#"<script type="module" src="/app-x82z.js"></script>"#));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[test]
|
|
142
|
+
fn test_generate_default_template() {
|
|
143
|
+
let html = HtmlInjector::generate_default("My App");
|
|
144
|
+
assert!(html.contains("<title>My App</title>"));
|
|
145
|
+
assert!(html.contains("<div id=\"app\"></div>"));
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/lib.rs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//! Zenith Bundler
|
|
2
|
+
//!
|
|
3
|
+
//! Rolldown Plugin for the Zenith Framework.
|
|
4
|
+
//!
|
|
5
|
+
//! This crate acts as the **Intelligence Layer** that feeds the Zenith compiler
|
|
6
|
+
//! output to Rolldown, implementing:
|
|
7
|
+
//!
|
|
8
|
+
//! - **Deferred Hydration**: Bootstrap loader with dynamic imports
|
|
9
|
+
//! - **Capability-Based Chunking**: Separate runtime-core and runtime-anim
|
|
10
|
+
//! - **CSS Pruning**: Tree-shake unused Tailwind via ZenManifest.css_classes
|
|
11
|
+
//! - **HTML Injection**: Inject hashed script/CSS links and modulepreload
|
|
12
|
+
//!
|
|
13
|
+
//! # Architecture
|
|
14
|
+
//!
|
|
15
|
+
//! ```text
|
|
16
|
+
//! .zen files → ZenithPlugin (resolve_id/load) → Rolldown Engine → Optimized Output
|
|
17
|
+
//! ```
|
|
18
|
+
|
|
19
|
+
pub mod bundler;
|
|
20
|
+
pub mod css;
|
|
21
|
+
pub mod html;
|
|
22
|
+
pub mod plugin;
|
|
23
|
+
pub mod store;
|
|
24
|
+
|
|
25
|
+
pub use css::CssBuffer;
|
|
26
|
+
pub use html::HtmlInjector;
|
|
27
|
+
pub use plugin::ZenithPlugin;
|
|
28
|
+
|
|
29
|
+
// Re-export Rolldown types for convenience
|
|
30
|
+
pub use rolldown::{Bundler, BundlerBuilder, BundlerOptions};
|
|
31
|
+
pub use rolldown_plugin::Plugin;
|
|
32
|
+
|
|
33
|
+
// --- NAPI Integration ---
|
|
34
|
+
|
|
35
|
+
#[cfg(feature = "napi")]
|
|
36
|
+
use napi_derive::napi;
|
|
37
|
+
#[cfg(feature = "napi")]
|
|
38
|
+
use std::sync::Arc;
|
|
39
|
+
#[cfg(feature = "napi")]
|
|
40
|
+
use crate::store::AssetStore;
|
|
41
|
+
#[cfg(feature = "napi")]
|
|
42
|
+
use tokio::sync::{mpsc, oneshot};
|
|
43
|
+
|
|
44
|
+
#[cfg(feature = "napi")]
|
|
45
|
+
#[napi]
|
|
46
|
+
pub struct ZenithDevController {
|
|
47
|
+
store: Arc<AssetStore>,
|
|
48
|
+
rebuild_tx: mpsc::Sender<oneshot::Sender<()>>,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[cfg(feature = "napi")]
|
|
52
|
+
#[napi]
|
|
53
|
+
impl ZenithDevController {
|
|
54
|
+
#[napi(constructor)]
|
|
55
|
+
pub fn new(project_root: String) -> Self {
|
|
56
|
+
let store = Arc::new(AssetStore::new());
|
|
57
|
+
let store_clone = store.clone();
|
|
58
|
+
|
|
59
|
+
// Channel for rebuild signals (Robust HMR Pattern)
|
|
60
|
+
// Main thread sends (reply_channel) -> Builder builds -> Builder replies
|
|
61
|
+
let (tx, mut rx) = mpsc::channel::<oneshot::Sender<()>>(1);
|
|
62
|
+
|
|
63
|
+
// Spawn Watcher/Builder Thread
|
|
64
|
+
std::thread::spawn(move || {
|
|
65
|
+
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
66
|
+
rt.block_on(async move {
|
|
67
|
+
let mut bundler = crate::bundler::create_dev_bundler(
|
|
68
|
+
&format!("{}/src/main.zen", project_root),
|
|
69
|
+
Some(&format!("{}/src/components", project_root)),
|
|
70
|
+
store_clone
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Initial Build
|
|
74
|
+
match bundler.write().await {
|
|
75
|
+
Ok(_outputs) => {}, // println! removed
|
|
76
|
+
Err(_e) => {}, // eprintln! removed for silence? Or keep errors? User said "all logs". I'll keep errors if critical, but silence is cleaner for "library".
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Internal Watch Loop (Driven by NAPI calls)
|
|
80
|
+
while let Some(reply_tx) = rx.recv().await {
|
|
81
|
+
match bundler.write().await {
|
|
82
|
+
Ok(_) => {
|
|
83
|
+
let _ = reply_tx.send(());
|
|
84
|
+
}
|
|
85
|
+
Err(_e) => {
|
|
86
|
+
let _ = reply_tx.send(());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
Self { store, rebuild_tx: tx }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[napi]
|
|
97
|
+
pub fn get_asset(&self, path: String) -> Option<String> {
|
|
98
|
+
self.store.get(&path)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Trigger a rebuild and wait for completion
|
|
102
|
+
#[napi]
|
|
103
|
+
pub async fn rebuild(&self) -> napi::Result<()> {
|
|
104
|
+
let (reply_tx, reply_rx) = oneshot::channel();
|
|
105
|
+
self.rebuild_tx
|
|
106
|
+
.send(reply_tx)
|
|
107
|
+
.await
|
|
108
|
+
.map_err(|_| napi::Error::from_reason("Builder thread disconnected"))?;
|
|
109
|
+
|
|
110
|
+
reply_rx
|
|
111
|
+
.await
|
|
112
|
+
.map_err(|_| napi::Error::from_reason("Builder failed to reply"))?;
|
|
113
|
+
|
|
114
|
+
Ok(())
|
|
115
|
+
}
|
|
116
|
+
}
|