1use napi::bindgen_prelude::{Buffer, Function};
31use napi::Error as NapiError;
32use napi_derive::napi;
33use serde_json::Value;
34use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
35use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
36use webui_handler::plugin::webui::WebUIHydrationPlugin;
37use webui_handler::{Protocol as HandlerProtocol, RenderOptions, ResponseWriter, WebUIHandler};
38#[cfg(test)]
39use webui_protocol::WebUIProtocol;
40
41const STREAM_CHUNK_SIZE: usize = 16 * 1024;
42
43#[napi(object)]
45pub struct JsBuildStats {
46 pub duration_ms: f64,
48 pub fragment_count: u32,
50 pub component_count: u32,
52 pub css_file_count: u32,
54 pub protocol_size_bytes: u32,
56 pub token_count: u32,
58}
59
60#[napi(object)]
62pub struct JsBuildResult {
63 pub protocol: Buffer,
65 pub css_files: Vec<String>,
67 pub component_asset_files: Vec<String>,
69 pub warnings: Vec<String>,
72 pub stats: JsBuildStats,
74}
75
76#[napi(object)]
78pub struct JsProjectionManifest {
79 pub path: String,
81 pub json: String,
83}
84
85#[napi(object)]
87pub struct JsBuildOptions {
88 pub app_dir: String,
90 pub entry: Option<String>,
92 pub css: Option<String>,
94 pub dom: Option<String>,
96 pub plugin: Option<String>,
98 pub components: Option<Vec<String>>,
100 pub component_asset_roots: Option<Vec<String>>,
102 pub css_file_name_template: Option<String>,
104 pub css_public_base: Option<String>,
106 pub legal_comments: Option<String>,
108 pub theme: Option<String>,
110 pub projection_manifests: Option<Vec<String>>,
112 pub projection_manifest_objects: Option<Vec<JsProjectionManifest>>,
114}
115
116#[napi]
120#[allow(clippy::cast_possible_truncation)] pub fn build(options: JsBuildOptions) -> napi::Result<JsBuildResult> {
122 let css = options
123 .css
124 .map(|s| s.parse::<webui::CssStrategy>())
125 .transpose()
126 .map_err(NapiError::from_reason)?
127 .unwrap_or_default();
128
129 let dom = options
130 .dom
131 .map(|s| s.parse::<webui::DomStrategy>())
132 .transpose()
133 .map_err(NapiError::from_reason)?
134 .unwrap_or_default();
135
136 let plugin = options
137 .plugin
138 .map(|s| s.parse::<webui::Plugin>())
139 .transpose()
140 .map_err(NapiError::from_reason)?;
141
142 let legal_comments = options
143 .legal_comments
144 .map(|s| s.parse::<webui::LegalComments>())
145 .transpose()
146 .map_err(NapiError::from_reason)?
147 .unwrap_or_default();
148
149 let app_dir = std::path::PathBuf::from(&options.app_dir);
150 let theme = options
151 .theme
152 .as_deref()
153 .map(|theme| load_theme(theme, &app_dir))
154 .transpose()?;
155 let mut projection_manifests: Vec<webui::ProjectionManifestSource> = options
156 .projection_manifests
157 .unwrap_or_default()
158 .into_iter()
159 .map(std::path::PathBuf::from)
160 .map(Into::into)
161 .collect();
162 projection_manifests.extend(
163 options
164 .projection_manifest_objects
165 .unwrap_or_default()
166 .into_iter()
167 .map(|manifest| webui::ProjectionManifestSource::Inline {
168 manifest_path: std::path::PathBuf::from(manifest.path),
169 json: manifest.json,
170 }),
171 );
172
173 let build_options = webui::BuildOptions {
174 app_dir,
175 entry: options.entry.unwrap_or_else(|| "index.html".to_string()),
176 css,
177 dom,
178 plugin,
179 components: options.components.unwrap_or_default(),
180 component_asset_roots: options.component_asset_roots.unwrap_or_default(),
181 css_file_name_template: options
182 .css_file_name_template
183 .unwrap_or_else(|| webui::DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string()),
184 css_public_base: options.css_public_base,
185 legal_comments,
186 theme,
187 projection_manifests,
188 };
189
190 let result = webui::build(build_options)
191 .map_err(|e| NapiError::from_reason(format!("Build error: {}", e.chain_message())))?;
192
193 let css_files: Vec<String> = result
195 .css_files
196 .into_iter()
197 .flat_map(|(name, content)| [name, content])
198 .collect();
199 let component_asset_files: Vec<String> = result
200 .component_asset_files
201 .into_iter()
202 .flat_map(|file| [file.name, file.content])
203 .collect();
204 let warnings: Vec<String> = result.warnings.iter().map(|d| d.to_string()).collect();
205
206 Ok(JsBuildResult {
207 protocol: Buffer::from(result.protocol_bytes),
208 css_files,
209 component_asset_files,
210 warnings,
211 stats: JsBuildStats {
212 duration_ms: result.stats.duration.as_secs_f64() * 1000.0,
213 fragment_count: result.stats.fragment_count as u32,
214 component_count: result.stats.component_count as u32,
215 css_file_count: result.stats.css_file_count as u32,
216 protocol_size_bytes: result.stats.protocol_size_bytes as u32,
217 token_count: result.stats.token_count as u32,
218 },
219 })
220}
221
222fn load_theme(theme: &str, search_root: &std::path::Path) -> napi::Result<webui::TokenFile> {
223 let resolved = webui::resolve_theme_path(theme, search_root)
224 .map_err(|e| NapiError::from_reason(format!("Theme resolution error: {e}")))?;
225 webui::load_token_file(&resolved).map_err(|e| {
226 NapiError::from_reason(format!("Theme load error for {}: {e}", resolved.display()))
227 })
228}
229
230#[napi]
232pub fn inspect(protocol_data: Buffer) -> napi::Result<String> {
233 webui::inspect_bytes(&protocol_data)
234 .map_err(|e| NapiError::from_reason(format!("Inspect error: {e}")))
235}
236
237#[napi]
243pub struct Protocol {
244 inner: HandlerProtocol,
245 handler: WebUIHandler,
246}
247
248#[napi]
249impl Protocol {
250 #[napi(constructor)]
252 pub fn new(protocol_data: Buffer, plugin: Option<String>) -> napi::Result<Self> {
253 let inner = decode_protocol(&protocol_data)?;
254 let handler = create_handler(plugin)?;
255 Ok(Self { inner, handler })
256 }
257
258 #[napi]
260 pub fn render(
261 &self,
262 state_json: String,
263 entry: String,
264 request_path: String,
265 ) -> napi::Result<Buffer> {
266 let state = parse_state_json(&state_json)?;
267 let options = RenderOptions::new(&entry, &request_path);
268 let html = render_to_string(&self.handler, &self.inner, &state, &options)?;
269 Ok(Buffer::from(html.into_bytes()))
271 }
272
273 #[napi]
275 pub fn render_stream(
276 &self,
277 state_json: String,
278 entry: String,
279 request_path: String,
280 on_chunk: Function<String, ()>,
281 ) -> napi::Result<()> {
282 let state = parse_state_json(&state_json)?;
283 let options = RenderOptions::new(&entry, &request_path);
284 render_to_callback(&self.handler, &self.inner, &state, &options, &on_chunk)
285 }
286
287 #[napi]
289 pub fn render_partial(
290 &self,
291 state_json: String,
292 entry_id: String,
293 request_path: String,
294 inventory_hex: String,
295 ) -> napi::Result<String> {
296 self.inner
297 .render_partial(&state_json, &entry_id, &request_path, &inventory_hex)
298 .map_err(|e| NapiError::from_reason(format!("render_partial failed: {e}")))
299 }
300
301 #[napi]
303 pub fn render_component_templates(
304 &self,
305 component_tags: Vec<String>,
306 inventory_hex: String,
307 ) -> napi::Result<String> {
308 let tag_refs: Vec<&str> = component_tags.iter().map(String::as_str).collect();
309 let result = self
310 .inner
311 .render_component_templates(&tag_refs, &inventory_hex)
312 .map_err(|e| {
313 NapiError::from_reason(format!("render_component_templates failed: {e}"))
314 })?;
315 serde_json::to_string(&result)
316 .map_err(|e| NapiError::from_reason(format!("JSON serialize error: {e}")))
317 }
318
319 #[napi]
321 pub fn tokens(&self) -> Vec<String> {
322 self.inner.tokens().to_vec()
323 }
324}
325
326fn decode_protocol(protocol_data: &[u8]) -> napi::Result<HandlerProtocol> {
327 HandlerProtocol::from_protobuf(protocol_data)
328 .map_err(|e| NapiError::from_reason(format!("Protocol decode error: {e}")))
329}
330
331fn parse_state_json(state_json: &str) -> napi::Result<Value> {
332 serde_json::from_str(state_json)
333 .map_err(|e| NapiError::from_reason(format!("State JSON error: {e}")))
334}
335
336fn create_handler(plugin: Option<String>) -> napi::Result<WebUIHandler> {
337 let plugin = plugin
338 .map(|value| value.parse::<webui::Plugin>())
339 .transpose()
340 .map_err(NapiError::from_reason)?;
341 Ok(match plugin {
342 Some(webui::Plugin::Fast | webui::Plugin::FastV2) => {
343 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
344 }
345 Some(webui::Plugin::FastV3) => {
346 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
347 }
348 Some(webui::Plugin::WebUI) => {
349 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
350 }
351 None => WebUIHandler::new(),
352 })
353}
354
355struct BufferedWriter {
356 output: String,
357}
358
359impl BufferedWriter {
360 fn new() -> Self {
361 Self {
362 output: String::with_capacity(4096),
363 }
364 }
365}
366
367impl ResponseWriter for BufferedWriter {
368 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
369 self.output.push_str(content);
370 Ok(())
371 }
372
373 fn end(&mut self) -> webui_handler::Result<()> {
374 Ok(())
375 }
376}
377
378fn render_to_string(
379 handler: &WebUIHandler,
380 protocol: &HandlerProtocol,
381 state: &Value,
382 options: &RenderOptions<'_>,
383) -> napi::Result<String> {
384 let mut writer = BufferedWriter::new();
385 handler
386 .render(protocol, state, options, &mut writer)
387 .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
388 Ok(writer.output)
389}
390
391struct CallbackWriter<'a, 'env> {
393 callback: &'a Function<'env, String, ()>,
394 buffer: String,
395 error: Option<String>,
396}
397
398impl<'a, 'env> CallbackWriter<'a, 'env> {
399 fn new(callback: &'a Function<'env, String, ()>) -> Self {
400 Self {
401 callback,
402 buffer: String::with_capacity(STREAM_CHUNK_SIZE),
403 error: None,
404 }
405 }
406
407 fn flush(&mut self) {
408 if self.buffer.is_empty() || self.error.is_some() {
409 return;
410 }
411
412 let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
413 if let Err(error) = self.callback.call(chunk) {
414 let message = error.to_string();
417 if !message.contains("Value is not undefined") {
418 self.error = Some(message);
419 }
420 }
421 }
422}
423
424impl ResponseWriter for CallbackWriter<'_, '_> {
425 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
426 if self.error.is_some() {
427 return Ok(());
428 }
429 self.buffer.push_str(content);
430 if self.buffer.len() >= STREAM_CHUNK_SIZE {
431 self.flush();
432 }
433 Ok(())
434 }
435
436 fn end(&mut self) -> webui_handler::Result<()> {
437 self.flush();
438 Ok(())
439 }
440}
441
442fn render_to_callback(
443 handler: &WebUIHandler,
444 protocol: &HandlerProtocol,
445 state: &Value,
446 options: &RenderOptions<'_>,
447 on_chunk: &Function<String, ()>,
448) -> napi::Result<()> {
449 let mut writer = CallbackWriter::new(on_chunk);
450 handler
451 .render(protocol, state, options, &mut writer)
452 .map_err(|e| NapiError::from_reason(format!("Render error: {e}")))?;
453 writer.flush();
454
455 if let Some(error) = writer.error {
456 return Err(NapiError::from_reason(format!("Callback error: {error}")));
457 }
458 Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use std::collections::BTreeMap;
465 use webui_parser::HtmlParser;
466 use webui_protocol::projection_manifest::{
467 ProjectionAdapter, ProjectionComponent, ProjectionManifest, ProjectionProducer,
468 PRODUCER_NAME, SCHEMA_ID,
469 };
470
471 fn build_protocol(html: &str) -> Vec<u8> {
473 let mut parser = HtmlParser::new();
474 parser.parse("index.html", html).expect("parse failed");
475 let tokens = parser.take_tokens();
476 let protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
477 protocol.to_protobuf().expect("protobuf encode failed")
478 }
479
480 fn render_to_string(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
482 let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
483 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
484
485 let mut output = String::with_capacity(1024);
486 let handler = WebUIHandler::new();
487
488 struct StringWriter<'a> {
489 output: &'a mut String,
490 }
491 impl ResponseWriter for StringWriter<'_> {
492 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
493 self.output.push_str(content);
494 Ok(())
495 }
496 fn end(&mut self) -> webui_handler::Result<()> {
497 Ok(())
498 }
499 }
500
501 let mut writer = StringWriter {
502 output: &mut output,
503 };
504 handler
505 .render(
506 &protocol,
507 &state,
508 &RenderOptions::new("index.html", "/"),
509 &mut writer,
510 )
511 .map_err(|e| e.to_string())?;
512 Ok(output)
513 }
514
515 #[test]
516 fn test_simple_passthrough() {
517 let proto = build_protocol("<p>Hello</p>");
518 let result = render_to_string(&proto, "{}");
519 assert_eq!(result.as_deref(), Ok("<p>Hello</p>"));
520 }
521
522 #[test]
523 fn test_signal_substitution() {
524 let proto = build_protocol("Hello, {{name}}!");
525 let result = render_to_string(&proto, r#"{"name": "WebUI"}"#);
526 assert_eq!(result.as_deref(), Ok("Hello, WebUI!"));
527 }
528
529 #[test]
530 fn protocol_reuses_decoded_protocol_for_json_state() {
531 let proto = build_protocol("Hello, {{name}}!");
532 let protocol = Protocol::new(Buffer::from(proto), None).expect("protocol should load");
533
534 let first = protocol
535 .render(
536 r#"{"name":"First"}"#.to_string(),
537 "index.html".to_string(),
538 "/".to_string(),
539 )
540 .expect("first render should succeed");
541 let second = protocol
542 .render(
543 r#"{"name":"世界"}"#.to_string(),
544 "index.html".to_string(),
545 "/".to_string(),
546 )
547 .expect("second render should succeed");
548
549 assert_eq!(first.as_ref(), b"Hello, First!");
550 assert_eq!(second.as_ref(), "Hello, 世界!".as_bytes());
551 }
552
553 #[test]
554 fn test_for_loop() {
555 let proto = build_protocol("<ul><for each=\"item in items\"><li>{{item}}</li></for></ul>");
556 let result = render_to_string(&proto, r#"{"items": ["a", "b", "c"]}"#);
557 assert_eq!(
558 result.as_deref(),
559 Ok("<ul><li>a</li><li>b</li><li>c</li></ul>")
560 );
561 }
562
563 #[test]
564 fn test_if_condition_true() {
565 let proto = build_protocol("<if condition=\"show\"><p>Visible</p></if>");
566 let result = render_to_string(&proto, r#"{"show": true}"#);
567 assert_eq!(result.as_deref(), Ok("<p>Visible</p>"));
568 }
569
570 #[test]
571 fn test_if_condition_false() {
572 let proto = build_protocol("<if condition=\"show\"><p>Hidden</p></if>");
573 let result = render_to_string(&proto, r#"{"show": false}"#);
574 assert_eq!(result.as_deref(), Ok(""));
575 }
576
577 #[test]
578 fn test_html_escaping() {
579 let proto = build_protocol("<div>{{content}}</div>");
580 let state = r#"{"content": "<script>alert('xss')</script>"}"#;
581 let result = render_to_string(&proto, state).expect("render should succeed");
582 assert!(!result.contains("<script>"));
583 assert!(result.contains("<script>"));
584 }
585
586 #[test]
587 fn test_raw_signal() {
588 let proto = build_protocol("<div>{{{content}}}</div>");
589 let result = render_to_string(&proto, r#"{"content": "<b>bold</b>"}"#);
590 assert_eq!(result.as_deref(), Ok("<div><b>bold</b></div>"));
591 }
592
593 #[test]
594 fn test_invalid_json() {
595 let proto = build_protocol("<p>hi</p>");
596 let result = render_to_string(&proto, "NOT JSON");
597 assert!(result.is_err());
598 }
599
600 #[test]
601 fn test_empty_state() {
602 let proto = build_protocol("<p>static</p>");
603 let result = render_to_string(&proto, "{}");
604 assert_eq!(result.as_deref(), Ok("<p>static</p>"));
605 }
606
607 #[test]
608 fn test_nested_object_signal() {
609 let proto = build_protocol("{{user.name}}");
610 let result = render_to_string(&proto, r#"{"user": {"name": "Alice"}}"#);
611 assert_eq!(result.as_deref(), Ok("Alice"));
612 }
613
614 #[test]
615 fn test_invalid_protobuf() {
616 let result = render_to_string(&[0xFF, 0xFF, 0xFF], "{}");
617 assert!(result.is_err());
618 }
619
620 fn build_projected_protocol(html: &str, schema: &[&str]) -> Vec<u8> {
622 let mut parser = HtmlParser::new();
623 parser.parse("index.html", html).expect("parse failed");
624 let tokens = parser.take_tokens();
625 let mut protocol = WebUIProtocol::with_tokens(parser.into_fragment_records(), tokens);
626 protocol.fragments.insert(
627 "client-card".to_string(),
628 webui_protocol::FragmentList {
629 fragments: vec![webui_protocol::WebUIFragment::raw("<p>client</p>")],
630 },
631 );
632 protocol
633 .fragments
634 .get_mut("index.html")
635 .expect("index fragment should exist")
636 .fragments
637 .insert(1, webui_protocol::WebUIFragment::component("client-card"));
638 protocol.initial_state_strategy = webui_protocol::InitialStateStrategy::Components as i32;
639 protocol.components.insert(
640 "client-card".to_string(),
641 webui_protocol::ComponentData {
642 hydration_mode: webui_protocol::StateProjectionMode::Keys as i32,
643 hydration_keys: schema.iter().map(|key| (*key).to_string()).collect(),
644 ..Default::default()
645 },
646 );
647 protocol.to_protobuf().expect("protobuf encode failed")
648 }
649
650 fn render_with_webui_plugin(protocol_bytes: &[u8], state_json: &str) -> Result<String, String> {
654 let protocol = HandlerProtocol::from_protobuf(protocol_bytes).map_err(|e| e.to_string())?;
655 let state: Value = serde_json::from_str(state_json).map_err(|e| e.to_string())?;
656
657 let mut output = String::with_capacity(1024);
658 struct StringWriter<'a> {
659 output: &'a mut String,
660 }
661 impl ResponseWriter for StringWriter<'_> {
662 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
663 self.output.push_str(content);
664 Ok(())
665 }
666 fn end(&mut self) -> webui_handler::Result<()> {
667 Ok(())
668 }
669 }
670 let mut writer = StringWriter {
671 output: &mut output,
672 };
673 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
674 handler
675 .render(
676 &protocol,
677 &state,
678 &RenderOptions::new("index.html", "/"),
679 &mut writer,
680 )
681 .map_err(|e| e.to_string())?;
682 Ok(output)
683 }
684
685 #[test]
686 fn render_projects_state_to_component_hydration_keys() {
687 let bytes =
690 build_projected_protocol("<html><body><p>{{kept}}</p></body></html>", &["kept"]);
691 let out =
692 render_with_webui_plugin(&bytes, r#"{"kept":"KEPT_VALUE","dropped":"DROPPED_VALUE"}"#)
693 .expect("render should succeed");
694
695 assert!(
697 out.contains(r#""kept":"KEPT_VALUE""#),
698 "hydratable key missing from bootstrap state: {out}"
699 );
700 assert!(
702 !out.contains("DROPPED_VALUE"),
703 "server-only value leaked: {out}"
704 );
705 assert!(
706 !out.contains("dropped"),
707 "server-only key name leaked: {out}"
708 );
709 }
710
711 fn projection_manifest_json() -> String {
714 const EMPTY_SHA256: &str =
715 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
716 let mut manifest = ProjectionManifest {
717 schema: SCHEMA_ID.to_string(),
718 producer: ProjectionProducer {
719 name: PRODUCER_NAME.to_string(),
720 version: "0.0.18".to_string(),
721 },
722 adapter: ProjectionAdapter {
723 name: "test".to_string(),
724 bundler: "test@1.0.0".to_string(),
725 },
726 root: ".".to_string(),
727 analysis_hash: format!("sha256:{}", "1".repeat(64)),
728 build_id: String::new(),
729 inputs: BTreeMap::from([("demo-card.ts".to_string(), EMPTY_SHA256.to_string())]),
730 outputs: BTreeMap::from([("bundle.js".to_string(), EMPTY_SHA256.to_string())]),
731 components: BTreeMap::from([(
732 "demo-card".to_string(),
733 ProjectionComponent {
734 module: "demo-card.ts".to_string(),
735 outputs: vec!["bundle.js".to_string()],
736 hydration_keys: vec!["name".to_string()],
737 navigation_keys: vec!["label".to_string(), "name".to_string()],
738 },
739 )]),
740 };
741 manifest.build_id = manifest.compute_build_id();
742 serde_json::to_string(&manifest).unwrap()
743 }
744
745 fn projection_build_options(app_dir: &std::path::Path) -> JsBuildOptions {
746 JsBuildOptions {
747 app_dir: app_dir.to_string_lossy().to_string(),
748 entry: None,
749 css: None,
750 dom: None,
751 plugin: Some("webui".to_string()),
752 components: None,
753 component_asset_roots: None,
754 css_file_name_template: None,
755 css_public_base: None,
756 legal_comments: None,
757 theme: None,
758 projection_manifests: None,
759 projection_manifest_objects: None,
760 }
761 }
762
763 #[test]
764 fn test_build_accepts_projection_paths_and_inline_objects() {
765 let dir = tempfile::TempDir::new().unwrap();
766 std::fs::write(dir.path().join("index.html"), "<demo-card></demo-card>").unwrap();
767 std::fs::write(dir.path().join("demo-card.html"), "<p>{{name}}</p>").unwrap();
768 std::fs::write(dir.path().join("demo-card.ts"), "").unwrap();
769 std::fs::write(dir.path().join("bundle.js"), "").unwrap();
770 let manifest_path = dir.path().join("projection.json");
771 let json = projection_manifest_json();
772 std::fs::write(&manifest_path, &json).unwrap();
773
774 let mut path_options = projection_build_options(dir.path());
775 path_options.projection_manifests = Some(vec![manifest_path.to_string_lossy().to_string()]);
776 let path_result = build(path_options).unwrap();
777 let path_protocol = WebUIProtocol::from_protobuf(&path_result.protocol).unwrap();
778 assert_eq!(
779 path_protocol.components["demo-card"].hydration_keys,
780 ["name"]
781 );
782
783 std::fs::remove_file(&manifest_path).unwrap();
784 let mut inline_options = projection_build_options(dir.path());
785 inline_options.projection_manifest_objects = Some(vec![JsProjectionManifest {
786 path: manifest_path.to_string_lossy().to_string(),
787 json,
788 }]);
789 let inline_result = build(inline_options).unwrap();
790 let inline_protocol = WebUIProtocol::from_protobuf(&inline_result.protocol).unwrap();
791 assert_eq!(
792 inline_protocol.components["demo-card"].navigation_keys,
793 ["label", "name"]
794 );
795 }
796
797 #[test]
798 fn test_build_simple_app() {
799 let dir = tempfile::TempDir::new().unwrap();
800 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
801
802 let options = JsBuildOptions {
803 app_dir: dir.path().to_string_lossy().to_string(),
804 entry: None,
805 css: None,
806 dom: None,
807 plugin: None,
808 components: None,
809 component_asset_roots: None,
810 css_file_name_template: None,
811 css_public_base: None,
812 legal_comments: None,
813 theme: None,
814 projection_manifests: None,
815 projection_manifest_objects: None,
816 };
817
818 let result = build(options).unwrap();
819 assert!(!result.protocol.is_empty());
820 assert!(result.stats.fragment_count > 0);
821 assert!(result.stats.protocol_size_bytes > 0);
822 assert!(result.stats.duration_ms >= 0.0);
823 }
824
825 #[test]
826 fn test_build_with_custom_entry() {
827 let dir = tempfile::TempDir::new().unwrap();
828 std::fs::write(dir.path().join("page.html"), "<p>Custom</p>").unwrap();
829
830 let options = JsBuildOptions {
831 app_dir: dir.path().to_string_lossy().to_string(),
832 entry: Some("page.html".to_string()),
833 css: None,
834 dom: None,
835 plugin: None,
836 components: None,
837 component_asset_roots: None,
838 css_file_name_template: None,
839 css_public_base: None,
840 legal_comments: None,
841 theme: None,
842 projection_manifests: None,
843 projection_manifest_objects: None,
844 };
845
846 let result = build(options).unwrap();
847 assert!(!result.protocol.is_empty());
848 }
849
850 #[test]
851 fn test_build_missing_app_dir() {
852 let options = JsBuildOptions {
853 app_dir: "/nonexistent/path".to_string(),
854 entry: None,
855 css: None,
856 dom: None,
857 plugin: None,
858 components: None,
859 component_asset_roots: None,
860 css_file_name_template: None,
861 css_public_base: None,
862 legal_comments: None,
863 theme: None,
864 projection_manifests: None,
865 projection_manifest_objects: None,
866 };
867
868 let result = build(options);
869 assert!(result.is_err());
870 }
871
872 #[test]
873 fn test_build_invalid_css() {
874 let dir = tempfile::TempDir::new().unwrap();
875 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
876
877 let options = JsBuildOptions {
878 app_dir: dir.path().to_string_lossy().to_string(),
879 entry: None,
880 css: Some("bogus".to_string()),
881 dom: None,
882 plugin: None,
883 components: None,
884 component_asset_roots: None,
885 css_file_name_template: None,
886 css_public_base: None,
887 legal_comments: None,
888 theme: None,
889 projection_manifests: None,
890 projection_manifest_objects: None,
891 };
892
893 let result = build(options);
894 assert!(result.is_err());
895 }
896
897 #[test]
898 fn test_build_with_components_css() {
899 let dir = tempfile::TempDir::new().unwrap();
900 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
901 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
902 std::fs::write(dir.path().join("my-card.css"), ".card { color: red; }").unwrap();
903
904 let options = JsBuildOptions {
905 app_dir: dir.path().to_string_lossy().to_string(),
906 entry: None,
907 css: Some("link".to_string()),
908 dom: None,
909 plugin: None,
910 components: None,
911 component_asset_roots: None,
912 css_file_name_template: None,
913 css_public_base: None,
914 legal_comments: None,
915 theme: None,
916 projection_manifests: None,
917 projection_manifest_objects: None,
918 };
919
920 let result = build(options).unwrap();
921 assert_eq!(result.css_files.len(), 2);
923 assert_eq!(result.css_files[0], "my-card.css");
924 assert!(result.css_files[1].contains("color: red"));
925 assert_eq!(result.stats.css_file_count, 1);
926 }
927
928 #[test]
929 fn test_build_with_theme_missing_token_errors() {
930 let dir = tempfile::TempDir::new().unwrap();
931 std::fs::write(dir.path().join("index.html"), "<my-card></my-card>").unwrap();
932 std::fs::write(dir.path().join("my-card.html"), "<div>Card</div>").unwrap();
933 std::fs::write(
934 dir.path().join("my-card.css"),
935 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
936 )
937 .unwrap();
938 let theme_path = dir.path().join("theme.json");
939 std::fs::write(&theme_path, r#"{"themes":{"light":{"token-b":"green"}}}"#).unwrap();
940
941 let options = JsBuildOptions {
942 app_dir: dir.path().to_string_lossy().to_string(),
943 entry: None,
944 css: Some("link".to_string()),
945 dom: None,
946 plugin: None,
947 components: None,
948 component_asset_roots: None,
949 css_file_name_template: None,
950 css_public_base: None,
951 legal_comments: None,
952 theme: Some(theme_path.to_string_lossy().to_string()),
953 projection_manifests: None,
954 projection_manifest_objects: None,
955 };
956
957 let Err(err) = build(options) else {
958 panic!("missing theme token must fail");
959 };
960 let message = err.to_string();
961 assert!(message.contains("missing-theme-token"), "msg: {message}");
962 assert!(message.contains("--token-c"), "msg: {message}");
963 }
964
965 #[test]
966 fn test_build_returns_component_asset_files() {
967 let dir = tempfile::TempDir::new().unwrap();
968 std::fs::write(dir.path().join("index.html"), "<app-shell></app-shell>").unwrap();
969 std::fs::write(dir.path().join("app-shell.html"), "<div></div>").unwrap();
970 std::fs::write(dir.path().join("lazy-panel.html"), "<p>{{title}}</p>").unwrap();
971 std::fs::write(dir.path().join("lazy-panel.ts"), "export {};").unwrap();
972
973 let options = JsBuildOptions {
974 app_dir: dir.path().to_string_lossy().to_string(),
975 entry: None,
976 css: Some("link".to_string()),
977 dom: None,
978 plugin: Some("webui".to_string()),
979 components: None,
980 component_asset_roots: Some(vec!["lazy-panel".to_string()]),
981 css_file_name_template: None,
982 css_public_base: None,
983 legal_comments: None,
984 theme: None,
985 projection_manifests: None,
986 projection_manifest_objects: None,
987 };
988
989 let result = build(options).unwrap();
990
991 assert_eq!(result.component_asset_files.len(), 2);
992 assert_eq!(result.component_asset_files[0], "lazy-panel.webui.js");
993 assert!(result.component_asset_files[1].contains("webui-component-asset"));
994 assert!(result.component_asset_files[1].contains("export default asset;"));
995 }
996
997 #[test]
998 fn test_build_legal_comments_none_strips_legal_css() {
999 let dir = tempfile::TempDir::new().unwrap();
1000 std::fs::write(dir.path().join("index.html"), "<my-card>Hello</my-card>").unwrap();
1001 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1002 std::fs::write(
1003 dir.path().join("my-card.css"),
1004 "/*! @license MIT */ .card { color: red; }",
1005 )
1006 .unwrap();
1007
1008 let options = JsBuildOptions {
1009 app_dir: dir.path().to_string_lossy().to_string(),
1010 entry: None,
1011 css: Some("link".to_string()),
1012 dom: None,
1013 plugin: None,
1014 components: None,
1015 component_asset_roots: None,
1016 css_file_name_template: None,
1017 css_public_base: None,
1018 legal_comments: Some("none".to_string()),
1019 theme: None,
1020 projection_manifests: None,
1021 projection_manifest_objects: None,
1022 };
1023
1024 let result = build(options).unwrap();
1025 assert_eq!(result.css_files[1], " .card { color: red; }");
1026 }
1027
1028 #[test]
1029 fn test_build_invalid_legal_comments() {
1030 let dir = tempfile::TempDir::new().unwrap();
1031 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1032
1033 let options = JsBuildOptions {
1034 app_dir: dir.path().to_string_lossy().to_string(),
1035 entry: None,
1036 css: None,
1037 dom: None,
1038 plugin: None,
1039 components: None,
1040 component_asset_roots: None,
1041 css_file_name_template: None,
1042 css_public_base: None,
1043 legal_comments: Some("linked".to_string()),
1044 theme: None,
1045 projection_manifests: None,
1046 projection_manifest_objects: None,
1047 };
1048
1049 let result = build(options);
1050 assert!(result.is_err());
1051 }
1052
1053 #[test]
1054 fn test_build_with_light_dom_omits_shadow_root_template() {
1055 let dir = tempfile::TempDir::new().unwrap();
1056 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1057 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1058
1059 let options = JsBuildOptions {
1060 app_dir: dir.path().to_string_lossy().to_string(),
1061 entry: None,
1062 css: None,
1063 dom: Some("light".to_string()),
1064 plugin: None,
1065 components: None,
1066 component_asset_roots: None,
1067 css_file_name_template: None,
1068 css_public_base: None,
1069 legal_comments: None,
1070 theme: None,
1071 projection_manifests: None,
1072 projection_manifest_objects: None,
1073 };
1074
1075 let result = build(options).unwrap();
1076 let json = inspect(result.protocol).unwrap();
1077 assert!(
1078 !json.contains("shadowrootmode"),
1079 "light DOM build should not emit shadowrootmode wrappers, got: {json}"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_build_with_shadow_dom_emits_shadow_root_template() {
1085 let dir = tempfile::TempDir::new().unwrap();
1086 std::fs::write(dir.path().join("index.html"), "<my-card>Hi</my-card>").unwrap();
1087 std::fs::write(dir.path().join("my-card.html"), "<div><slot></slot></div>").unwrap();
1088
1089 let options = JsBuildOptions {
1090 app_dir: dir.path().to_string_lossy().to_string(),
1091 entry: None,
1092 css: None,
1093 dom: Some("shadow".to_string()),
1094 plugin: None,
1095 components: None,
1096 component_asset_roots: None,
1097 css_file_name_template: None,
1098 css_public_base: None,
1099 legal_comments: None,
1100 theme: None,
1101 projection_manifests: None,
1102 projection_manifest_objects: None,
1103 };
1104
1105 let result = build(options).unwrap();
1106 let json = inspect(result.protocol).unwrap();
1107 assert!(
1108 json.contains("shadowrootmode"),
1109 "shadow DOM build should emit shadowrootmode wrappers, got: {json}"
1110 );
1111 }
1112
1113 #[test]
1114 fn test_build_invalid_dom() {
1115 let dir = tempfile::TempDir::new().unwrap();
1116 std::fs::write(dir.path().join("index.html"), "<h1>Hello</h1>").unwrap();
1117
1118 let options = JsBuildOptions {
1119 app_dir: dir.path().to_string_lossy().to_string(),
1120 entry: None,
1121 css: None,
1122 dom: Some("bogus".to_string()),
1123 plugin: None,
1124 components: None,
1125 component_asset_roots: None,
1126 css_file_name_template: None,
1127 css_public_base: None,
1128 legal_comments: None,
1129 theme: None,
1130 projection_manifests: None,
1131 projection_manifest_objects: None,
1132 };
1133
1134 let result = build(options);
1135 assert!(result.is_err());
1136 }
1137
1138 #[test]
1139 fn test_inspect_valid_protocol() {
1140 let proto = build_protocol("<h1>Hello {{name}}</h1>");
1141 let json = inspect(napi::bindgen_prelude::Buffer::from(proto)).unwrap();
1142 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1143 assert!(parsed.get("fragments").is_some());
1144 }
1145
1146 #[test]
1147 fn test_inspect_invalid_protocol() {
1148 let result = inspect(napi::bindgen_prelude::Buffer::from(vec![0xFF, 0xFF]));
1149 assert!(result.is_err());
1150 }
1151
1152 #[test]
1153 fn test_protocol_tokens_empty() {
1154 let proto = build_protocol("<p>Hello</p>");
1155 let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1156 let tokens = protocol.tokens();
1157 assert!(tokens.is_empty());
1158 }
1159
1160 #[test]
1161 fn test_protocol_tokens_returns_parsed_tokens() {
1162 let mut parser = HtmlParser::new();
1164 parser.parse("index.html", "<p>Hi</p>").expect("parse");
1165 let protocol = WebUIProtocol::with_tokens(
1166 parser.into_fragment_records(),
1167 vec![
1168 "colorBrandBackground".to_string(),
1169 "fontSizeBase300".to_string(),
1170 ],
1171 );
1172 let proto = protocol.to_protobuf().expect("encode");
1173 let protocol = Protocol::new(Buffer::from(proto), None).unwrap();
1174 let tokens = protocol.tokens();
1175 assert_eq!(tokens, vec!["colorBrandBackground", "fontSizeBase300"]);
1176 }
1177
1178 #[test]
1179 fn test_protocol_tokens_invalid_protobuf() {
1180 let result = Protocol::new(Buffer::from(vec![0xFF, 0xFF]), None);
1181 assert!(result.is_err());
1182 }
1183}