@zenithbuild/bundler 1.3.10 → 1.3.16

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/bundler.rs ADDED
@@ -0,0 +1,81 @@
1
+ //! Zenith Bundler Configuration
2
+ //!
3
+ //! Provides helper functions to create a pre-configured Rolldown bundler
4
+ //! with Zenith's required settings (capability splitting, plugin injection, etc).
5
+
6
+ use rolldown::{Bundler, BundlerBuilder, BundlerOptions, InputItem};
7
+ use std::sync::Arc;
8
+
9
+ use crate::plugin::ZenithPlugin;
10
+
11
+ /// Create a configured Rolldown bundler for a Zenith project
12
+ pub fn create_zenith_bundler(entry_point: &str, components_dir: Option<&str>) -> Bundler {
13
+ // 1. Initialize the Zenith Plugin
14
+ let mut plugin = ZenithPlugin::new(entry_point);
15
+ if let Some(dir) = components_dir {
16
+ plugin = plugin.with_components_dir(dir);
17
+ }
18
+
19
+ // 2. Configure Bundler Options
20
+ // Note: manual_chunks is currently experimental in Rolldown Rust API,
21
+ // we'll need to verify the exact API surface.
22
+ // For now, we rely on the plugin's dynamic imports to drive chunking naturally,
23
+ // and we can enhance this with explicit manual_chunks if the API allows.
24
+
25
+ let options = BundlerOptions {
26
+ input: Some(vec![InputItem {
27
+ name: Some("index".into()),
28
+ import: "virtual:zenith-entry".into(), // Start with our Hydration Controller
29
+ }]),
30
+ // Enable code splitting
31
+ format: Some(rolldown_common::OutputFormat::Esm),
32
+ // Ensure we target browser environment
33
+ platform: Some(rolldown_common::Platform::Browser),
34
+ // Capability-Based Chunking:
35
+ // GSAP should be handled as a separate chunk (capability: "anim").
36
+ // We rely on dynamic imports (import('gsap')) in the user's code to automatically split it.
37
+ // Explicit manual_chunks can be added here if stricter control is needed.
38
+ // Capability-based chunking configuration will go here once verified
39
+ ..Default::default()
40
+ };
41
+
42
+ let builder = BundlerBuilder::default()
43
+ .with_options(options)
44
+ .with_plugins(vec![Arc::new(plugin)]);
45
+
46
+ builder.build().expect("Failed to build bundler")
47
+ }
48
+
49
+ /// Create a configured Rolldown bundler for Dev Mode (Watch + HMR + InMemory)
50
+ pub fn create_dev_bundler(
51
+ entry_point: &str,
52
+ components_dir: Option<&str>,
53
+ store: std::sync::Arc<crate::store::AssetStore>,
54
+ ) -> Bundler {
55
+ // 1. Initialize the Zenith Plugin with Store and Dev Mode
56
+ let mut plugin = ZenithPlugin::new(entry_point)
57
+ .with_store(store)
58
+ .with_dev_mode(true);
59
+
60
+ if let Some(dir) = components_dir {
61
+ plugin = plugin.with_components_dir(dir);
62
+ }
63
+
64
+ // 2. Configure Bundler Options (Dev Optimized)
65
+ let options = BundlerOptions {
66
+ input: Some(vec![InputItem {
67
+ name: Some("index".into()),
68
+ import: "virtual:zenith-entry".into(),
69
+ }]),
70
+ format: Some(rolldown_common::OutputFormat::Esm),
71
+ platform: Some(rolldown_common::Platform::Browser),
72
+ sourcemap: Some(rolldown_common::SourceMapType::File), // Enable sourcemaps for dev
73
+ ..Default::default()
74
+ };
75
+
76
+ let builder = BundlerBuilder::default()
77
+ .with_options(options)
78
+ .with_plugins(vec![Arc::new(plugin)]);
79
+
80
+ builder.build().expect("Failed to build dev bundler")
81
+ }
package/src/bundler.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @zenithbuild/bundler - Page Script Bundler
3
+ *
4
+ * COMPILER-FIRST ARCHITECTURE
5
+ * ═══════════════════════════════════════════════════════════════════════════════
6
+ *
7
+ * This bundler performs ZERO inference. It executes exactly what the compiler specifies.
8
+ *
9
+ * Rules:
10
+ * - If a BundlePlan is provided, bundling MUST occur
11
+ * - If bundling fails, throw a hard error (no fallback)
12
+ * - The bundler never inspects source code for intent
13
+ * - No temp files, no heuristics, no recovery
14
+ *
15
+ * Bundler failure = compiler bug.
16
+ * ═══════════════════════════════════════════════════════════════════════════════
17
+ */
18
+
19
+ import { rolldown } from 'rolldown'
20
+ import type { BundlePlan } from '@zenithbuild/compiler'
21
+
22
+ /**
23
+ * Execute a compiler-emitted BundlePlan
24
+ *
25
+ * This is a PURE PLAN EXECUTOR. It does not:
26
+ * - Inspect source code for imports
27
+ * - Decide whether bundling is needed
28
+ * - Fall back on failure
29
+ * - Use temp files
30
+ *
31
+ * @param plan - Compiler-emitted BundlePlan (must exist; caller must not call if no plan)
32
+ * @throws Error if bundling fails (no fallback, no recovery)
33
+ */
34
+ export async function bundlePageScript(plan: BundlePlan): Promise<string> {
35
+ // Virtual entry module ID
36
+ const VIRTUAL_ENTRY = '\0zenith:entry.tsx'
37
+
38
+ // Build virtual modules map from plan
39
+ const virtualModules = new Map<string, string>()
40
+ virtualModules.set(VIRTUAL_ENTRY, plan.entry)
41
+ for (const vm of plan.virtualModules) {
42
+ virtualModules.set(vm.id, vm.code)
43
+ }
44
+
45
+ // Execute Rolldown with plan-specified configuration
46
+ // No inference, no heuristics, no semantic analysis
47
+ const bundle = await rolldown({
48
+ input: VIRTUAL_ENTRY,
49
+ platform: plan.platform,
50
+ resolve: {
51
+ modules: plan.resolveRoots
52
+ },
53
+ plugins: [{
54
+ name: 'zenith-virtual',
55
+ resolveId(source: string) {
56
+ // Virtual modules from plan
57
+ if (virtualModules.has(source)) {
58
+ return { id: source, moduleSideEffects: true }
59
+ }
60
+ // Special case: zenith:content namespace
61
+ if (source === 'zenith:content') {
62
+ return { id: '\0zenith:content', moduleSideEffects: true }
63
+ }
64
+ return null
65
+ },
66
+ load(id: string) {
67
+ return virtualModules.get(id) ?? null
68
+ }
69
+ }],
70
+ // DETERMINISTIC OUTPUT: Disable all semantic optimizations
71
+ // Tree-shaking implies semantic analysis - bundler must not infer
72
+ treeshake: false,
73
+ // HARD FAILURE on unresolved imports - no silent external treatment
74
+ onLog(level: string, log: any) {
75
+ if (log.code === 'UNRESOLVED_IMPORT') {
76
+ throw new Error(
77
+ `[Zenith Bundler] Unresolved import: ${log.message}. ` +
78
+ `This is a compiler error - the BundlePlan references a module that cannot be resolved.`
79
+ )
80
+ }
81
+ }
82
+ })
83
+
84
+ // Generate output with plan-specified format
85
+ const { output } = await bundle.generate({
86
+ format: plan.format
87
+ })
88
+
89
+ // Hard failure if no output - this is a compiler bug
90
+ if (!output[0]?.code) {
91
+ throw new Error(
92
+ '[Zenith Bundler] Rolldown produced no output. ' +
93
+ 'This is a compiler error - the BundlePlan was invalid or Rolldown failed silently.'
94
+ )
95
+ }
96
+
97
+ return output[0].code
98
+ }
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
+ }