@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/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
+ }