@zenithbuild/bundler 1.3.7 → 1.3.15

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.ts ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Zenith CSS Compiler Module
3
+ *
4
+ * Compiler-owned CSS processing that integrates Tailwind CSS v4 JIT
5
+ * at compile time. This module ensures:
6
+ *
7
+ * 1. All CSS is processed at build time (no runtime generation)
8
+ * 2. Tailwind sees all .zen templates for class scanning
9
+ * 3. HMR support for instant CSS updates in dev mode
10
+ * 4. Deterministic, cacheable output for production
11
+ *
12
+ * Per Zenith CSS Directive: The compiler owns styles.
13
+ */
14
+
15
+ import { spawn, spawnSync } from 'child_process'
16
+ import path from 'path'
17
+ import fs from 'fs'
18
+
19
+ // ============================================
20
+ // Types
21
+ // ============================================
22
+
23
+ export interface CSSCompileOptions {
24
+ /** Input CSS file path (e.g., src/styles/globals.css) */
25
+ input: string
26
+ /** Output CSS file path, or ':memory:' for in-memory result */
27
+ output: string
28
+ /** Enable minification for production */
29
+ minify?: boolean
30
+ /** Watch mode for HMR */
31
+ watch?: boolean
32
+ }
33
+
34
+ export interface CSSCompileResult {
35
+ /** Compiled CSS content */
36
+ css: string
37
+ /** Compilation time in milliseconds */
38
+ duration: number
39
+ /** Whether compilation succeeded */
40
+ success: boolean
41
+ /** Error message if failed */
42
+ error?: string
43
+ }
44
+
45
+ // ============================================
46
+ // CSS Compilation
47
+ // ============================================
48
+
49
+ /**
50
+ * Compile CSS using Tailwind CSS v4 CLI
51
+ */
52
+ export function compileCss(options: CSSCompileOptions): CSSCompileResult {
53
+ const startTime = performance.now()
54
+ const { input, output, minify = false } = options
55
+
56
+ // Validate input exists
57
+ if (!fs.existsSync(input)) {
58
+ return {
59
+ css: '',
60
+ duration: 0,
61
+ success: false,
62
+ error: `CSS input file not found: ${input}`
63
+ }
64
+ }
65
+
66
+ try {
67
+ // Build Tailwind CLI arguments
68
+ const args = [
69
+ '@tailwindcss/cli',
70
+ '-i', input
71
+ ]
72
+
73
+ // For in-memory compilation, use stdout
74
+ const useStdout = output === ':memory:'
75
+ if (!useStdout) {
76
+ args.push('-o', output)
77
+ }
78
+
79
+ if (minify) {
80
+ args.push('--minify')
81
+ }
82
+
83
+ // Execute Tailwind CLI synchronously
84
+ const result = spawnSync('bunx', args, {
85
+ cwd: path.dirname(input),
86
+ encoding: 'utf-8',
87
+ stdio: useStdout ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'inherit', 'pipe'],
88
+ env: { ...process.env }
89
+ })
90
+
91
+ const duration = Math.round(performance.now() - startTime)
92
+
93
+ if (result.status !== 0) {
94
+ const errorMsg = result.stderr?.toString() || 'Unknown compilation error'
95
+ return {
96
+ css: '',
97
+ duration,
98
+ success: false,
99
+ error: `Tailwind compilation failed: ${errorMsg}`
100
+ }
101
+ }
102
+
103
+ // Get CSS content
104
+ let css = ''
105
+ if (useStdout) {
106
+ css = result.stdout?.toString() || ''
107
+ } else if (fs.existsSync(output)) {
108
+ css = fs.readFileSync(output, 'utf-8')
109
+ }
110
+
111
+ return {
112
+ css,
113
+ duration,
114
+ success: true
115
+ }
116
+
117
+ } catch (error: any) {
118
+ return {
119
+ css: '',
120
+ duration: Math.round(performance.now() - startTime),
121
+ success: false,
122
+ error: error.message
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Compile CSS asynchronously (non-blocking)
129
+ */
130
+ export async function compileCssAsync(options: CSSCompileOptions): Promise<CSSCompileResult> {
131
+ return new Promise((resolve) => {
132
+ const startTime = performance.now()
133
+ const { input, output, minify = false } = options
134
+
135
+ if (!fs.existsSync(input)) {
136
+ resolve({
137
+ css: '',
138
+ duration: 0,
139
+ success: false,
140
+ error: `CSS input file not found: ${input}`
141
+ })
142
+ return
143
+ }
144
+
145
+ const args = ['@tailwindcss/cli', '-i', input]
146
+ const useStdout = output === ':memory:'
147
+
148
+ if (!useStdout) {
149
+ args.push('-o', output)
150
+ }
151
+
152
+ if (minify) {
153
+ args.push('--minify')
154
+ }
155
+
156
+ const child = spawn('bunx', args, {
157
+ cwd: path.dirname(input),
158
+ stdio: useStdout ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'inherit', 'pipe'],
159
+ env: { ...process.env }
160
+ })
161
+
162
+ let stdout = ''
163
+ let stderr = ''
164
+
165
+ if (useStdout && child.stdout) {
166
+ child.stdout.on('data', (data) => { stdout += data.toString() })
167
+ }
168
+
169
+ if (child.stderr) {
170
+ child.stderr.on('data', (data) => { stderr += data.toString() })
171
+ }
172
+
173
+ child.on('close', (code) => {
174
+ const duration = Math.round(performance.now() - startTime)
175
+
176
+ if (code !== 0) {
177
+ resolve({
178
+ css: '',
179
+ duration,
180
+ success: false,
181
+ error: `Tailwind compilation failed: ${stderr}`
182
+ })
183
+ return
184
+ }
185
+
186
+ let css = ''
187
+ if (useStdout) {
188
+ css = stdout
189
+ } else if (fs.existsSync(output)) {
190
+ css = fs.readFileSync(output, 'utf-8')
191
+ }
192
+
193
+ resolve({
194
+ css,
195
+ duration,
196
+ success: true
197
+ })
198
+ })
199
+
200
+ child.on('error', (err) => {
201
+ resolve({
202
+ css: '',
203
+ duration: Math.round(performance.now() - startTime),
204
+ success: false,
205
+ error: err.message
206
+ })
207
+ })
208
+ })
209
+ }
210
+
211
+ export interface CSSWatchOptions extends CSSCompileOptions {
212
+ /** Callback when CSS changes */
213
+ onChange: (result: CSSCompileResult) => void
214
+ /** Debounce delay in ms */
215
+ debounce?: number
216
+ }
217
+
218
+ /**
219
+ * Watch CSS and source files for changes, recompile on change
220
+ */
221
+ export function watchCss(options: CSSWatchOptions): () => void {
222
+ const { input, output, minify, onChange, debounce = 100 } = options
223
+
224
+ let timeout: NodeJS.Timeout | null = null
225
+ let isCompiling = false
226
+
227
+ const recompile = async () => {
228
+ if (isCompiling) return
229
+ isCompiling = true
230
+
231
+ const result = await compileCssAsync({ input, output, minify })
232
+ onChange(result)
233
+
234
+ isCompiling = false
235
+ }
236
+
237
+ const debouncedRecompile = () => {
238
+ if (timeout) clearTimeout(timeout)
239
+ timeout = setTimeout(recompile, debounce)
240
+ }
241
+
242
+ // Watch the styles directory
243
+ const stylesDir = path.dirname(input)
244
+ const stylesWatcher = fs.watch(stylesDir, { recursive: true }, (event, filename) => {
245
+ if (filename?.endsWith('.css')) {
246
+ debouncedRecompile()
247
+ }
248
+ })
249
+
250
+ // Watch source files that Tailwind scans (for class changes)
251
+ const srcDir = path.resolve(stylesDir, '..')
252
+ let srcWatcher: fs.FSWatcher | null = null
253
+
254
+ if (fs.existsSync(srcDir)) {
255
+ srcWatcher = fs.watch(srcDir, { recursive: true }, (event, filename) => {
256
+ if (filename?.endsWith('.zen') || filename?.endsWith('.tsx') || filename?.endsWith('.jsx')) {
257
+ debouncedRecompile()
258
+ }
259
+ })
260
+ }
261
+
262
+ // Return cleanup function
263
+ return () => {
264
+ if (timeout) clearTimeout(timeout)
265
+ stylesWatcher.close()
266
+ srcWatcher?.close()
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Resolve the canonical globals.css path for a Zenith project
272
+ */
273
+ export function resolveGlobalsCss(projectRoot: string): string | null {
274
+ // Check for globals.css (canonical)
275
+ const globalsPath = path.join(projectRoot, 'src', 'styles', 'globals.css')
276
+ if (fs.existsSync(globalsPath)) return globalsPath
277
+
278
+ // Check for global.css (legacy)
279
+ const globalPath = path.join(projectRoot, 'src', 'styles', 'global.css')
280
+ if (fs.existsSync(globalPath)) return globalPath
281
+
282
+ return null
283
+ }
284
+
285
+ /**
286
+ * Get the output path for compiled CSS
287
+ */
288
+ export function getCompiledCssPath(projectRoot: string, mode: 'dev' | 'build'): string {
289
+ if (mode === 'build') {
290
+ return path.join(projectRoot, 'dist', 'assets', 'styles.css')
291
+ }
292
+ // In dev mode, we use in-memory compilation
293
+ return ':memory:'
294
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Generate Final Bundle
3
+ *
4
+ * Phase 8/9/10: Generate final browser-ready bundle
5
+ *
6
+ * Combines:
7
+ * - Compiled HTML
8
+ * - Runtime JS
9
+ * - Expression functions
10
+ * - Event bindings
11
+ * - Style injection
12
+ */
13
+
14
+ import type { FinalizedOutput } from '@zenithbuild/compiler'
15
+
16
+ /**
17
+ * Generate final bundle code
18
+ *
19
+ * This is the complete JavaScript bundle that will execute in the browser.
20
+ * All expressions are pre-compiled - no template parsing at runtime.
21
+ */
22
+ export function generateFinalBundle(finalized: FinalizedOutput): string {
23
+ return `// Zenith Compiled Bundle (Phase 8/9/10)
24
+ // Generated at compile time - no .zen parsing in browser
25
+ // All expressions are pre-compiled - deterministic output
26
+
27
+ ${finalized.js}
28
+
29
+ // Bundle complete - ready for browser execution
30
+ `
31
+ }
32
+
33
+ /**
34
+ * Generate HTML with inline script
35
+ */
36
+ export function generateHTMLWithScript(
37
+ html: string,
38
+ jsBundle: string,
39
+ styles: string[]
40
+ ): string {
41
+ // Inject styles as <style> tags
42
+ const styleTags = styles.map(style => `<style>${escapeHTML(style)}</style>`).join('\n')
43
+
44
+ // Inject JS bundle as inline script
45
+ const scriptTag = `<script>${jsBundle}</script>`
46
+
47
+ // Find </head> or <body> to inject styles
48
+ // Find </body> to inject script
49
+ let result = html
50
+
51
+ if (styleTags) {
52
+ if (result.includes('</head>')) {
53
+ result = result.replace('</head>', `${styleTags}\n</head>`)
54
+ } else if (result.includes('<body')) {
55
+ result = result.replace('<body', `${styleTags}\n<body`)
56
+ }
57
+ }
58
+
59
+ if (scriptTag) {
60
+ if (result.includes('</body>')) {
61
+ result = result.replace('</body>', `${scriptTag}\n</body>`)
62
+ } else {
63
+ result += scriptTag
64
+ }
65
+ }
66
+
67
+ return result
68
+ }
69
+
70
+ /**
71
+ * Escape HTML for safe embedding
72
+ */
73
+ function escapeHTML(str: string): string {
74
+ return str
75
+ .replace(/&/g, '&amp;')
76
+ .replace(/</g, '&lt;')
77
+ .replace(/>/g, '&gt;')
78
+ .replace(/"/g, '&quot;')
79
+ .replace(/'/g, '&#039;')
80
+ }
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/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './bundle-generator'
2
+ export * from './generateFinalBundle'
3
+ export * from './build-analyzer'
4
+ export * from './bundler'
5
+ export * from './css'
6
+ export * from './runtime-generator'
7
+ export * from './types'
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
+ }