Skip to main content

webui_node/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Node.js native addon for the WebUI framework via napi-rs.
5//!
6//! Provides high-performance server-side rendering by compiling the Rust
7//! WebUI handler directly into a `.node` native addon — no C ABI intermediary.
8//!
9//! The `render` function accepts pre-compiled protobuf protocol data (from
10//! `webui build`) and streams rendered HTML fragments via a callback, enabling
11//! true streaming SSR without buffering the entire response.
12//!
13//! ## Usage (from Node.js)
14//!
15//! ```js
16//! import fs from 'node:fs';
17//!
18//! // Load the native addon
19//! const mod = { exports: {} };
20//! process.dlopen(mod, './target/release/libwebui_node.dylib');
21//! const { render } = mod.exports;
22//!
23//! // Read pre-compiled protocol (from `webui build`)
24//! const protocol = fs.readFileSync('./dist/protocol.bin');
25//! const state = '{"name": "WebUI"}';
26//!
27//! // Stream rendered fragments
28//! render(protocol, state, (chunk) => process.stdout.write(chunk));
29//! ```
30
31use napi::bindgen_prelude::{Buffer, Function};
32use napi::Error as NapiError;
33use napi_derive::napi;
34use serde_json::Value;
35use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
36use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
37use webui_handler::plugin::webui::WebUIHydrationPlugin;
38use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
39use webui_protocol::WebUIProtocol;
40
41/// Build statistics returned from the build function.
42#[napi(object)]
43pub struct JsBuildStats {
44    /// Build duration in milliseconds.
45    pub duration_ms: f64,
46    /// Total number of protocol fragments.
47    pub fragment_count: u32,
48    /// Number of registered components.
49    pub component_count: u32,
50    /// Number of CSS files produced.
51    pub css_file_count: u32,
52    /// Size of the serialized protocol in bytes.
53    pub protocol_size_bytes: u32,
54    /// Number of unique CSS tokens discovered.
55    pub token_count: u32,
56}
57
58/// Result of a successful build operation.
59#[napi(object)]
60pub struct JsBuildResult {
61    /// Serialized protocol (protobuf binary).
62    pub protocol: Buffer,
63    /// CSS files as alternating [filename, content, filename, content, ...].
64    pub css_files: Vec<String>,
65    /// Static component asset files as alternating [filename, content, filename, content, ...].
66    pub component_asset_files: Vec<String>,
67    /// Non-fatal build advisories (plain text), e.g. CSS tokens used only with a
68    /// literal `var()` fallback and absent from every theme.
69    pub warnings: Vec<String>,
70    /// Build statistics.
71    pub stats: JsBuildStats,
72}
73
74/// Build options for the webui build API.
75#[napi(object)]
76pub struct JsBuildOptions {
77    /// Path to the application folder containing templates.
78    pub app_dir: String,
79    /// Entry HTML file name (defaults to "index.html").
80    pub entry: Option<String>,
81    /// CSS mode: "link" (default) or "style".
82    pub css: Option<String>,
83    /// DOM strategy for component rendering: "shadow" (default) or "light".
84    pub dom: Option<String>,
85    /// Plugin identifier (see crate documentation for available identifiers).
86    pub plugin: Option<String>,
87    /// Additional component sources (npm packages or local paths).
88    pub components: Option<Vec<String>>,
89    /// Root component tags emitted as static `.webui.js` ESM assets.
90    pub component_asset_roots: Option<Vec<String>>,
91    /// Link-mode CSS filename template using [name], [hash], [ext].
92    pub css_file_name_template: Option<String>,
93    /// Optional base URL/path prefix for Link-mode css hrefs.
94    pub css_public_base: Option<String>,
95    /// Legal comment handling: "inline" (default) or "none".
96    pub legal_comments: Option<String>,
97    /// Design token theme: a JSON file path or npm package name.
98    pub theme: Option<String>,
99}
100
101/// Build a WebUI application from an app directory.
102///
103/// Returns the compiled protocol bytes, CSS files, and build statistics.
104#[napi]
105#[allow(clippy::cast_possible_truncation)] // stats are bounded by component/file counts
106pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
107    let css = options
108        .css
109        .map(|s| s.parse::<webui::CssStrategy>())
110        .transpose()
111        .map_err(NapiError::from_reason)?
112        .unwrap_or_default();
113
114    let dom = options
115        .dom
116        .map(|s| s.parse::<webui::DomStrategy>())
117        .transpose()
118        .map_err(NapiError::from_reason)?
119        .unwrap_or_default();
120
121    let plugin = options
122        .plugin
123        .map(|s| s.parse::<webui::Plugin>())
124        .transpose()
125        .map_err(NapiError::from_reason)?;
126
127    let legal_comments = options
128        .legal_comments
129        .map(|s| s.parse::<webui::LegalComments>())
130        .transpose()
131        .map_err(NapiError::from_reason)?
132        .unwrap_or_default();
133
134    let app_dir = std::path::PathBuf::from(&options.app_dir);
135    let theme = options
136        .theme
137        .as_deref()
138        .map(|theme| load_theme(theme, &app_dir))
139        .transpose()?;
140
141    let build_options = webui::BuildOptions {
142        app_dir,
143        entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
144        css,
145        dom,
146        plugin,
147        components: options.components.unwrap_or_default(),
148        component_asset_roots: options.component_asset_roots.unwrap_or_default(),
149        css_file_name_template: options
150            .css_file_name_template
151            .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
152        css_public_base: options.css_public_base,
153        legal_comments,
154        theme,
155    };
156
157    let result = webui::build(build_options)
158        .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
159
160    // Flatten css_files into alternating [filename, content, ...] for JS interop
161    let css_files: Vec<String> = result
162        .css_files
163        .into_iter()
164        .flat_map(|(name, content)| [name, content])
165        .collect();
166    let component_asset_files: Vec<String> = result
167        .component_asset_files
168        .into_iter()
169        .flat_map(|file| [file.name, file.content])
170        .collect();
171    let warnings: Vec<String> = result.warnings.iter().map(|d| d.to_string()).collect();
172
173    Ok(JsBuildResult {
174        protocol: Buffer::from(result.protocol_bytes),
175        css_files,
176        component_asset_files,
177        warnings,
178        stats: JsBuildStats {
179            duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
180            fragment_count: result.stats.fragment_count as u32,
181            component_count: result.stats.component_count as u32,
182            css_file_count: result.stats.css_file_count as u32,
183            protocol_size_bytes: result.stats.protocol_size_bytes as u32,
184            token_count: result.stats.token_count as u32,
185        },
186    })
187}
188
189fn load_theme(theme: &str, search_root: &std::path::Path) -> napi::Result<webui::TokenFile> {
190    let resolved = webui::resolve_theme_path(theme, search_root)
191        .map_err(|e| NapiError::from_reason(format!("Theme resolution error: {e}")))?;
192    webui::load_token_file(&resolved).map_err(|e| {
193        NapiError::from_reason(format!("Theme load error for {}: {e}", resolved.display()))
194    })
195}
196
197/// Inspect protocol bytes and return a JSON representation.
198#[napi]
199pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
200    webui::inspect_bytes(&protocol_data)
201        .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
202}
203
204/// Produce a complete JSON partial response for client-side navigation.
205///
206/// Combines application state, route templates, inventory, request path, and
207/// matched route chain into a single JSON string:
208/// `{"state":{...},"templates":[...],"inventory":"...","path":"...","chain":[...]}`.
209///
210/// Host servers return this directly — no assembly required.
211#[napi]
212pub fn render_partial(
213    protocol_data: Buffer,
214    state_json: String,
215    entry_id: String,
216    request_path: String,
217    inventory_hex: String,
218) -> napi::Result<String> {
219    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
220        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
221
222    let state: serde_json::Value = serde_json::from_str(&state_json)
223        .map_err(|e| NapiError::from_reason(format!("invalid state JSON: {e}")))?;
224
225    // TODO: ProtocolIndex is created per-request here because the Node binding
226    // receives the protocol fresh each call. Ideally the host should cache it
227    // alongside the protocol for the lifetime of the server — it's deterministic
228    // per protocol and avoids rebuilding the component index + route cache.
229    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
230
231    let mut result = webui_handler::route_handler::render_partial(
232        &protocol,
233        &entry_id,
234        &request_path,
235        &inventory_hex,
236        &mut index,
237    )
238    .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))?;
239    if let Some(obj) = result.as_object_mut() {
240        obj.insert("state".into(), state);
241    }
242
243    serde_json::to_string(&result)
244        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
245}
246
247#[napi]
248pub fn render_component_templates(
249    protocol_data: Buffer,
250    component_tags_json: String,
251    inventory_hex: String,
252) -> napi::Result<String> {
253    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
254        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
255
256    let tags: Vec<String> = serde_json::from_str(&component_tags_json)
257        .map_err(|e| NapiError::from_reason(format!("invalid tags JSON: {e}")))?;
258    let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
259
260    // Per-request index — see ProtocolIndex doc for caching guidance.
261    let mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
262
263    let result = webui_handler::route_handler::render_component_templates(
264        &protocol,
265        &tag_refs,
266        &inventory_hex,
267        &mut index,
268    )
269    .map_err(|e| NapiError::from_reason(format!("render_component_templates failed: {e}")))?;
270
271    serde_json::to_string(&result)
272        .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
273}
274
275/// Extract the CSS token name list from a compiled protocol.
276///
277/// Returns the tokens as a JavaScript string array, preserving the original
278/// order from the build step.
279#[napi]
280pub fn protocol_tokens(protocol_data: Buffer) -> napi::Result<Vec<String>> {
281    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
282        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
283
284    Ok(protocol.tokens)
285}
286
287/// A writer that streams each rendered fragment to a JS callback.
288struct CallbackWriter<'a, 'env> {
289    callback: &'a Function<'env, String, ()>,
290    error: Option<String>,
291}
292
293impl<'a, 'env> CallbackWriter<'a, 'env> {
294    fn new(callback: &'a Function<'env, String, ()>) -> Self {
295        Self {
296            callback,
297            error: None,
298        }
299    }
300}
301
302impl ResponseWriter for CallbackWriter<'_, '_> {
303    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
304        if self.error.is_some() {
305            return Ok(());
306        }
307        if let Err(e) = self.callback.call(content.to_owned()) {
308            // Ignore "Value is not undefined" errors from callbacks that
309            // return non-void (e.g. res.write() returns a boolean).
310            let msg = format!("{e}");
311            if !msg.contains("Value is not undefined") {
312                self.error = Some(msg);
313            }
314        }
315        Ok(())
316    }
317
318    fn end(&mut self) -> webui_handler::Result<()> {
319        Ok(())
320    }
321}
322
323/// Render a pre-compiled WebUI protocol with JSON state, streaming each
324/// fragment to the provided callback.
325///
326/// # Arguments
327///
328/// * `protocol_data` — Protobuf binary from `webui build` (zero-copy Buffer).
329/// * `state_json` — JSON string with the render state.
330/// * `on_chunk` — Called with each rendered HTML fragment as it is produced.
331/// * `plugin` — Optional plugin identifier (see crate documentation for available identifiers).
332#[napi]
333#[allow(clippy::too_many_arguments)]
334pub fn render(
335    protocol_data: Buffer,
336    state_json: String,
337    entry: String,
338    request_path: String,
339    on_chunk: Function<String, ()>,
340    plugin: Option<String>,
341) -> napi::Result<()> {
342    let protocol = WebUIProtocol::from_protobuf(&protocol_data)
343        .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))?;
344
345    let state: Value = serde_json::from_str(&state_json)
346        .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))?;
347
348    let mut writer = CallbackWriter::new(&on_chunk);
349    let plugin = plugin
350        .map(|s| s.parse::<webui::Plugin>())
351        .transpose()
352        .map_err(NapiError::from_reason)?;
353    let handler = match plugin {
354        Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
355            WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
356        }
357        Some(webui::Plugin::FastV3) => {
358            WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
359        }
360        Some(webui::Plugin::WebUI) => {
361            WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
362        }
363        None => WebUIHandler::new(),
364    };
365    handler
366        .render(
367            &protocol,
368            &state,
369            &RenderOptions::new(&entry, &request_path),
370            &mut writer,
371        )
372        .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
373
374    if let Some(err) = writer.error {
375        return Err(NapiError::from_reason(format!("Callback error: {err}")));
376    }
377
378    Ok(())
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use webui_parser::HtmlParser;
385
386    /// Helper: parse HTML into protobuf bytes for testing.
387    fn build_protocol(html: &str) -> Vec<u8> {
388        let mut parser = HtmlParser::new();
389        parser.parse("index.html", html).expect("parse failed");
390        let tokens = parser.take_tokens();
391        let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
392        protocol.to_protobuf().expect("protobuf encode failed")
393    }
394
395    /// Helper: render protocol bytes + state, collecting output into a String.
396    fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
397        let protocol = WebUIProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
398        let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
399
400        let mut output = String::with_capacity(1024);
401        let handler = WebUIHandler::new();
402
403        struct StringWriter<'a> {
404            output: &'a mut String,
405        }
406        impl ResponseWriter for StringWriter<'_> {
407            fn write(&mut self, content: &str) -> webui_handler::Result<()> {
408                self.output.push_str(content);
409                Ok(())
410            }
411            fn end(&mut self) -> webui_handler::Result<()> {
412                Ok(())
413            }
414        }
415
416        let mut writer = StringWriter {
417            output: &mut output,
418        };
419        handler
420            .render(
421                &protocol,
422                &state,
423                &RenderOptions::new("index.html", "/"),
424                &mut writer,
425            )
426            .map_err(|e| e.to_string())?;
427        Ok(output)
428    }
429
430    #[test]
431    fn test_simple_passthrough() {
432        let proto = build_protocol("<p>Hello</p>");
433        let result = render_to_string(&proto, "{}");
434        assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
435    }
436
437    #[test]
438    fn test_signal_substitution() {
439        let proto = build_protocol("Hello, {{name}}!");
440        let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
441        assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
442    }
443
444    #[test]
445    fn test_for_loop() {
446        let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
447        let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
448        assert_eq!(
449            result.as_deref(),
450            Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
451        );
452    }
453
454    #[test]
455    fn test_if_condition_true() {
456        let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
457        let result = render_to_string(&proto, r#"{"show": true}"#);
458        assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
459    }
460
461    #[test]
462    fn test_if_condition_false() {
463        let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
464        let result = render_to_string(&proto, r#"{"show": false}"#);
465        assert_eq!(result.as_deref(), Ok(""));
466    }
467
468    #[test]
469    fn test_html_escaping() {
470        let proto = build_protocol("<div>{{content}}</div>");
471        let state = r#"{"content": "<script>alert('xss')</script>"}"#;
472        let result = render_to_string(&proto, state).expect("render should succeed");
473        assert!(!result.contains("<script>"));
474        assert!(result.contains("&lt;script&gt;"));
475    }
476
477    #[test]
478    fn test_raw_signal() {
479        let proto = build_protocol("<div>{{{content}}}</div>");
480        let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
481        assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
482    }
483
484    #[test]
485    fn test_invalid_json() {
486        let proto = build_protocol("<p>hi</p>");
487        let result = render_to_string(&proto, "NOT JSON");
488        assert!(result.is_err());
489    }
490
491    #[test]
492    fn test_empty_state() {
493        let proto = build_protocol("<p>static</p>");
494        let result = render_to_string(&proto, "{}");
495        assert_eq!(result.as_deref(), Ok("<p>static</p>"));
496    }
497
498    #[test]
499    fn test_nested_object_signal() {
500        let proto = build_protocol("{{user.name}}");
501        let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
502        assert_eq!(result.as_deref(), Ok("Alice"));
503    }
504
505    #[test]
506    fn test_invalid_protobuf() {
507        let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
508        assert!(result.is_err());
509    }
510
511    // ── Tests for build() and inspect() napi exports ─────────────────
512
513    #[test]
514    fn test_build_simple_app() {
515        let dir = tempfile::TempDir::new().unwrap();
516        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
517
518        let options = JsBuildOptions {
519            app_dir: dir.path().to_string_lossy().to_string(),
520            entry: None,
521            css: None,
522            dom: None,
523            plugin: None,
524            components: None,
525            component_asset_roots: None,
526            css_file_name_template: None,
527            css_public_base: None,
528            legal_comments: None,
529            theme: None,
530        };
531
532        let result = build(options).unwrap();
533        assert!(!result.protocol.is_empty());
534        assert!(result.stats.fragment_count > 0);
535        assert!(result.stats.protocol_size_bytes > 0);
536        assert!(result.stats.duration_ms >= 0.0);
537    }
538
539    #[test]
540    fn test_build_with_custom_entry() {
541        let dir = tempfile::TempDir::new().unwrap();
542        std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
543
544        let options = JsBuildOptions {
545            app_dir: dir.path().to_string_lossy().to_string(),
546            entry: Some("page.html".to_string()),
547            css: None,
548            dom: None,
549            plugin: None,
550            components: None,
551            component_asset_roots: None,
552            css_file_name_template: None,
553            css_public_base: None,
554            legal_comments: None,
555            theme: None,
556        };
557
558        let result = build(options).unwrap();
559        assert!(!result.protocol.is_empty());
560    }
561
562    #[test]
563    fn test_build_missing_app_dir() {
564        let options = JsBuildOptions {
565            app_dir: "/nonexistent/path".to_string(),
566            entry: None,
567            css: None,
568            dom: None,
569            plugin: None,
570            components: None,
571            component_asset_roots: None,
572            css_file_name_template: None,
573            css_public_base: None,
574            legal_comments: None,
575            theme: None,
576        };
577
578        let result = build(options);
579        assert!(result.is_err());
580    }
581
582    #[test]
583    fn test_build_invalid_css() {
584        let dir = tempfile::TempDir::new().unwrap();
585        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
586
587        let options = JsBuildOptions {
588            app_dir: dir.path().to_string_lossy().to_string(),
589            entry: None,
590            css: Some("bogus".to_string()),
591            dom: None,
592            plugin: None,
593            components: None,
594            component_asset_roots: None,
595            css_file_name_template: None,
596            css_public_base: None,
597            legal_comments: None,
598            theme: None,
599        };
600
601        let result = build(options);
602        assert!(result.is_err());
603    }
604
605    #[test]
606    fn test_build_with_components_css() {
607        let dir = tempfile::TempDir::new().unwrap();
608        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
609        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
610        std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
611
612        let options = JsBuildOptions {
613            app_dir: dir.path().to_string_lossy().to_string(),
614            entry: None,
615            css: Some("link".to_string()),
616            dom: None,
617            plugin: None,
618            components: None,
619            component_asset_roots: None,
620            css_file_name_template: None,
621            css_public_base: None,
622            legal_comments: None,
623            theme: None,
624        };
625
626        let result = build(options).unwrap();
627        // css_files is flattened: [filename, content, filename, content, ...]
628        assert_eq!(result.css_files.len(), 2);
629        assert_eq!(result.css_files[0], "my-card.css");
630        assert!(result.css_files[1].contains("color: red"));
631        assert_eq!(result.stats.css_file_count, 1);
632    }
633
634    #[test]
635    fn test_build_with_theme_missing_token_errors() {
636        let dir = tempfile::TempDir::new().unwrap();
637        std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
638        std::fs::write(dir.path().join("my-card.html"), "<div>Card</div>").unwrap();
639        std::fs::write(
640            dir.path().join("my-card.css"),
641            ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
642        )
643        .unwrap();
644        let theme_path = dir.path().join("theme.json");
645        std::fs::write(&theme_path, r#"{"themes":{"light":{"token-b":"green"}}}"#).unwrap();
646
647        let options = JsBuildOptions {
648            app_dir: dir.path().to_string_lossy().to_string(),
649            entry: None,
650            css: Some("link".to_string()),
651            dom: None,
652            plugin: None,
653            components: None,
654            component_asset_roots: None,
655            css_file_name_template: None,
656            css_public_base: None,
657            legal_comments: None,
658            theme: Some(theme_path.to_string_lossy().to_string()),
659        };
660
661        let Err(err) = build(options) else {
662            panic!("missing theme token must fail");
663        };
664        let message = err.to_string();
665        assert!(message.contains("missing-theme-token"), "msg: {message}");
666        assert!(message.contains("--token-c"), "msg: {message}");
667    }
668
669    #[test]
670    fn test_build_returns_component_asset_files() {
671        let dir = tempfile::TempDir::new().unwrap();
672        std::fs::write(dir.path().join("index.html"), "<app-shell></app-shell>").unwrap();
673        std::fs::write(dir.path().join("app-shell.html"), "<div></div>").unwrap();
674        std::fs::write(dir.path().join("lazy-panel.html"), "<p>{{title}}</p>").unwrap();
675
676        let options = JsBuildOptions {
677            app_dir: dir.path().to_string_lossy().to_string(),
678            entry: None,
679            css: Some("link".to_string()),
680            dom: None,
681            plugin: Some("webui".to_string()),
682            components: None,
683            component_asset_roots: Some(vec!["lazy-panel".to_string()]),
684            css_file_name_template: None,
685            css_public_base: None,
686            legal_comments: None,
687            theme: None,
688        };
689
690        let result = build(options).unwrap();
691
692        assert_eq!(result.component_asset_files.len(), 2);
693        assert_eq!(result.component_asset_files[0], "lazy-panel.webui.js");
694        assert!(result.component_asset_files[1].contains("webui-component-asset"));
695        assert!(result.component_asset_files[1].contains("export default asset;"));
696    }
697
698    #[test]
699    fn test_build_legal_comments_none_strips_legal_css() {
700        let dir = tempfile::TempDir::new().unwrap();
701        std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
702        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
703        std::fs::write(
704            dir.path().join("my-card.css"),
705            "/*! @license MIT */ .card { color: red; }",
706        )
707        .unwrap();
708
709        let options = JsBuildOptions {
710            app_dir: dir.path().to_string_lossy().to_string(),
711            entry: None,
712            css: Some("link".to_string()),
713            dom: None,
714            plugin: None,
715            components: None,
716            component_asset_roots: None,
717            css_file_name_template: None,
718            css_public_base: None,
719            legal_comments: Some("none".to_string()),
720            theme: None,
721        };
722
723        let result = build(options).unwrap();
724        assert_eq!(result.css_files[1], " .card { color: red; }");
725    }
726
727    #[test]
728    fn test_build_invalid_legal_comments() {
729        let dir = tempfile::TempDir::new().unwrap();
730        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
731
732        let options = JsBuildOptions {
733            app_dir: dir.path().to_string_lossy().to_string(),
734            entry: None,
735            css: None,
736            dom: None,
737            plugin: None,
738            components: None,
739            component_asset_roots: None,
740            css_file_name_template: None,
741            css_public_base: None,
742            legal_comments: Some("linked".to_string()),
743            theme: None,
744        };
745
746        let result = build(options);
747        assert!(result.is_err());
748    }
749
750    #[test]
751    fn test_build_with_light_dom_omits_shadow_root_template() {
752        let dir = tempfile::TempDir::new().unwrap();
753        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
754        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
755
756        let options = JsBuildOptions {
757            app_dir: dir.path().to_string_lossy().to_string(),
758            entry: None,
759            css: None,
760            dom: Some("light".to_string()),
761            plugin: None,
762            components: None,
763            component_asset_roots: None,
764            css_file_name_template: None,
765            css_public_base: None,
766            legal_comments: None,
767            theme: None,
768        };
769
770        let result = build(options).unwrap();
771        let json = inspect(result.protocol).unwrap();
772        assert!(
773            !json.contains("shadowrootmode"),
774            "light DOM build should not emit shadowrootmode wrappers, got: {json}"
775        );
776    }
777
778    #[test]
779    fn test_build_with_shadow_dom_emits_shadow_root_template() {
780        let dir = tempfile::TempDir::new().unwrap();
781        std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
782        std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
783
784        let options = JsBuildOptions {
785            app_dir: dir.path().to_string_lossy().to_string(),
786            entry: None,
787            css: None,
788            dom: Some("shadow".to_string()),
789            plugin: None,
790            components: None,
791            component_asset_roots: None,
792            css_file_name_template: None,
793            css_public_base: None,
794            legal_comments: None,
795            theme: None,
796        };
797
798        let result = build(options).unwrap();
799        let json = inspect(result.protocol).unwrap();
800        assert!(
801            json.contains("shadowrootmode"),
802            "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
803        );
804    }
805
806    #[test]
807    fn test_build_invalid_dom() {
808        let dir = tempfile::TempDir::new().unwrap();
809        std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
810
811        let options = JsBuildOptions {
812            app_dir: dir.path().to_string_lossy().to_string(),
813            entry: None,
814            css: None,
815            dom: Some("bogus".to_string()),
816            plugin: None,
817            components: None,
818            component_asset_roots: None,
819            css_file_name_template: None,
820            css_public_base: None,
821            legal_comments: None,
822            theme: None,
823        };
824
825        let result = build(options);
826        assert!(result.is_err());
827    }
828
829    #[test]
830    fn test_inspect_valid_protocol() {
831        let proto = build_protocol("<h1>Hello {{name}}</h1>");
832        let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
833        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
834        assert!(parsed.get("fragments").is_some());
835    }
836
837    #[test]
838    fn test_inspect_invalid_protocol() {
839        let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
840        assert!(result.is_err());
841    }
842
843    // ── Tests for protocol_tokens napi export ────────────────────────
844
845    #[test]
846    fn test_protocol_tokens_empty() {
847        let proto = build_protocol("<p>Hello</p>");
848        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
849        assert!(tokens.is_empty());
850    }
851
852    #[test]
853    fn test_protocol_tokens_returns_parsed_tokens() {
854        // Build from a protocol that has CSS tokens via with_tokens constructor.
855        let mut parser = HtmlParser::new();
856        parser.parse("index.html", "<p>Hi</p>").expect("parse");
857        let protocol = WebUIProtocol::with_tokens(
858            parser.into_fragment_records(),
859            vec![
860                "colorBrandBackground".to_string(),
861                "fontSizeBase300".to_string(),
862            ],
863        );
864        let proto = protocol.to_protobuf().expect("encode");
865        let tokens = protocol_tokens(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
866        assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
867    }
868
869    #[test]
870    fn test_protocol_tokens_invalid_protobuf() {
871        let result = protocol_tokens(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
872        assert!(result.is_err());
873    }
874}