Skip to main content

webui/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Programmatic API for the WebUI build-time rendering framework.
5//!
6//! This crate provides the core build, render, and inspection APIs
7//! that power the `webui` CLI, Node.js bindings, and WASM module.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use webui::{build, BuildOptions, CssStrategy, DomStrategy};
13//! use std::path::PathBuf;
14//!
15//! let result = build(BuildOptions {
16//!     app_dir: PathBuf::from("./src"),
17//!     entry: "index.html".to_string(),
18//!     css: CssStrategy::Link,
19//!     dom: DomStrategy::Shadow,
20//!     plugin: None,
21//!     components: Vec::new(),
22//! }).unwrap();
23//!
24//! println!("Built {} fragments in {:?}", result.stats.fragment_count, result.stats.duration);
25//! ```
26
27mod error;
28pub mod server;
29
30pub use error::WebUIError;
31
32// Re-export core types from downstream crates
33pub use webui_handler::route_handler::{
34    encode_inventory, get_needed_components, get_needed_components_for_request, parse_inventory,
35};
36pub use webui_handler::{plugin::HandlerPlugin, HandlerError, ResponseWriter, WebUIHandler};
37pub use webui_parser::CssStrategy;
38pub use webui_parser::DomStrategy;
39pub use webui_parser::Plugin;
40pub use webui_protocol::WebUIProtocol;
41
42use std::fs;
43use std::path::Path;
44use std::time::Instant;
45use webui_parser::plugin::fast::FastParserPlugin;
46use webui_parser::plugin::webui::WebUIParserPlugin;
47use webui_parser::plugin::ParserPluginArtifacts;
48use webui_parser::HtmlParser;
49
50/// Options for building a WebUI application.
51#[derive(Debug, Clone)]
52pub struct BuildOptions {
53    /// Path to the application folder containing templates.
54    pub app_dir: std::path::PathBuf,
55    /// Entry HTML file name (e.g., `"index.html"`).
56    pub entry: String,
57    /// CSS delivery strategy for component stylesheets.
58    pub css: CssStrategy,
59    /// DOM strategy for component rendering (shadow or light).
60    pub dom: DomStrategy,
61    /// Framework plugin to load.
62    pub plugin: Option<Plugin>,
63    /// Additional component sources (npm packages or local paths).
64    pub components: Vec<String>,
65}
66
67/// Statistics about a completed build.
68#[derive(Debug, Clone)]
69pub struct BuildStats {
70    /// Total wall-clock build time.
71    pub duration: std::time::Duration,
72    /// Total number of protocol fragments.
73    pub fragment_count: usize,
74    /// Number of registered components.
75    pub component_count: usize,
76    /// Number of CSS files produced.
77    pub css_file_count: usize,
78    /// Size of the serialized protocol in bytes.
79    pub protocol_size_bytes: usize,
80    /// Number of unique CSS tokens discovered.
81    pub token_count: usize,
82}
83
84/// Result of a successful build.
85#[derive(Debug)]
86pub struct BuildResult {
87    /// The compiled WebUI protocol.
88    pub protocol: WebUIProtocol,
89    /// Serialized protocol bytes (protobuf binary).
90    pub protocol_bytes: Vec<u8>,
91    /// Component CSS files: `(filename, content)` — only components referenced in the protocol.
92    pub css_files: Vec<(String, String)>,
93    /// Component f-template strings: `(tag_name, f_template_html)`.
94    /// Includes templates for all components encountered during parsing,
95    /// including route-referenced components.
96    pub component_templates: Vec<(String, String)>,
97    /// Build statistics.
98    pub stats: BuildStats,
99}
100
101/// Build a WebUI application from an app directory.
102///
103/// Parses templates, discovers components, and produces a compiled protocol
104/// with build statistics.
105///
106/// # Errors
107///
108/// Returns [`WebUIError`] if the app directory is invalid, templates fail
109/// to parse, or the protocol cannot be serialized.
110#[must_use = "BuildResult contains the compiled protocol and statistics"]
111pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
112    let started = Instant::now();
113
114    let raw = build_protocol_inner(&options)?;
115
116    let protocol_bytes = raw.protocol.to_protobuf()?;
117
118    let stats = BuildStats {
119        duration: started.elapsed(),
120        fragment_count: raw.fragment_count,
121        component_count: raw.component_count,
122        css_file_count: raw.css_files.len(),
123        protocol_size_bytes: protocol_bytes.len(),
124        token_count: raw.token_count,
125    };
126
127    Ok(BuildResult {
128        protocol: raw.protocol,
129        protocol_bytes,
130        css_files: raw.css_files,
131        component_templates: raw.component_templates,
132        stats,
133    })
134}
135
136/// Build a WebUI application and write output files to disk.
137///
138/// Writes `protocol.bin` and any external CSS files to `out_dir`.
139/// Creates `out_dir` if it does not exist.
140///
141/// # Errors
142///
143/// Returns [`WebUIError`] on build failure or if output files cannot be written.
144pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
145    let result = build(options)?;
146
147    fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
148        context: format!("Failed to create {}", out_dir.display()),
149        source,
150    })?;
151
152    fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
153        WebUIError::Io {
154            context: format!("Failed to write protocol.bin to {}", out_dir.display()),
155            source,
156        }
157    })?;
158
159    for (name, content) in &result.css_files {
160        fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
161            context: format!("Failed to write {name} to {}", out_dir.display()),
162            source,
163        })?;
164    }
165
166    Ok(result.stats)
167}
168
169/// Inspect a compiled WebUI protocol file and return its JSON representation.
170pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
171    let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
172        context: format!("Failed to read {}", protocol_path.display()),
173        source,
174    })?;
175    inspect_bytes(&bytes)
176}
177
178/// Inspect raw protocol bytes and return their JSON representation.
179pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
180    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
181    protocol
182        .to_json_pretty()
183        .map_err(|e| WebUIError::Serialization(e.to_string()))
184}
185
186/// Internal intermediate build output before stats are computed.
187struct RawBuildOutput {
188    protocol: WebUIProtocol,
189    css_files: Vec<(String, String)>,
190    component_templates: Vec<(String, String)>,
191    fragment_count: usize,
192    component_count: usize,
193    token_count: usize,
194}
195
196/// Internal build logic shared by `build()` and `build_to_disk()`.
197fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
198    let mut parser = match options.plugin {
199        Some(Plugin::Fast) => {
200            let mut plugin = FastParserPlugin::new();
201            plugin.set_css_strategy(options.css);
202            HtmlParser::with_plugin(Box::new(plugin))
203        }
204        Some(Plugin::WebUI) => {
205            let mut plugin = WebUIParserPlugin::new();
206            plugin.set_css_strategy(options.css);
207            plugin.set_dom_strategy(options.dom);
208            HtmlParser::with_plugin(Box::new(plugin))
209        }
210        None => HtmlParser::new(),
211    };
212    parser.set_css_strategy(options.css);
213    parser.set_dom_strategy(options.dom);
214
215    // Register app directory components
216    parser
217        .component_registry_mut()
218        .register_from_paths(&[&options.app_dir])
219        .map_err(|e| {
220            WebUIError::ComponentRegistration(format!(
221                "Failed to register components from {}: {e}",
222                options.app_dir.display()
223            ))
224        })?;
225
226    // Discover and register external component sources
227    for source in &options.components {
228        let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
229            WebUIError::ComponentDiscovery(format!(
230                "Failed to discover components from {source}: {e}"
231            ))
232        })?;
233        for comp in &result.components {
234            parser
235                .component_registry_mut()
236                .register_component(
237                    &comp.tag_name,
238                    &comp.html_content,
239                    comp.css_content.as_deref(),
240                )
241                .map_err(|e| {
242                    WebUIError::ComponentRegistration(format!(
243                        "Failed to register component '{}' from {}: {e}",
244                        comp.tag_name, comp.source
245                    ))
246                })?;
247        }
248    }
249
250    let component_count = parser.component_registry_mut().len();
251
252    // Parse entry HTML
253    let entry_path = options.app_dir.join(&options.entry);
254    let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
255        context: format!("Failed to read {}", entry_path.display()),
256        source,
257    })?;
258    parser
259        .parse(&options.entry, &html_content)
260        .map_err(|source| WebUIError::Parse {
261            context: format!("Failed to parse {}", options.entry),
262            source,
263        })?;
264
265    // Snapshot CSS before consuming the parser
266    let css_snapshot: Vec<(String, String)> = parser
267        .component_registry_mut()
268        .get_all()
269        .filter_map(|c| {
270            c.css_content
271                .as_ref()
272                .map(|css| (c.tag_name.clone(), css.clone()))
273        })
274        .collect();
275
276    // Collect CSS tokens before consuming the parser
277    let tokens = parser.take_tokens();
278    let token_count = tokens.len();
279
280    let component_templates = match parser.take_plugin_artifacts() {
281        ParserPluginArtifacts::None => Vec::new(),
282        ParserPluginArtifacts::ComponentTemplates(templates) => templates,
283    };
284
285    // Build protocol (consumes parser)
286    let fragment_records = parser.into_fragment_records();
287    let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
288
289    let mut protocol = WebUIProtocol::with_tokens(fragment_records, tokens);
290
291    // Process component CSS in a single pass: store Module CSS content,
292    // set Link-strategy css_href, and collect external CSS files.
293    let is_module = options.css == CssStrategy::Module;
294    let is_link = options.css == CssStrategy::Link;
295    // css_href is only used for Light DOM — Shadow DOM components embed
296    // their own <link> inside the shadow root template at parse time.
297    let needs_head_link = is_link && options.dom == DomStrategy::Light;
298    let mut css_files: Vec<(String, String)> = Vec::new();
299    for (tag, css) in css_snapshot {
300        if !protocol.fragments.contains_key(&tag) {
301            continue;
302        }
303        if is_module {
304            protocol.components.entry(tag).or_default().css = css.trim().to_string();
305        } else if is_link {
306            let safe_tag = tag.replace(['/', '\\'], "-");
307            if needs_head_link {
308                protocol.components.entry(tag).or_default().css_href = format!("/{safe_tag}.css");
309            }
310            css_files.push((format!("{safe_tag}.css"), css));
311        }
312        // Style strategy: CSS is already baked into raw fragments by the
313        // parser — nothing to store in the protocol or emit as files.
314    }
315
316    // Store client templates in the protocol so any host server can query them
317    for (tag, tmpl) in &component_templates {
318        protocol.components.entry(tag.clone()).or_default().template = tmpl.clone();
319    }
320
321    Ok(RawBuildOutput {
322        protocol,
323        css_files,
324        component_templates,
325        fragment_count,
326        component_count,
327        token_count,
328    })
329}
330
331#[cfg(test)]
332#[allow(clippy::disallowed_methods)]
333mod tests {
334    use super::*;
335    use std::path::PathBuf;
336    use tempfile::TempDir;
337    use webui_protocol::web_ui_fragment::Fragment;
338
339    fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
340        let dir = TempDir::new().unwrap();
341        for (name, content) in files {
342            let path = dir.path().join(name);
343            if let Some(parent) = path.parent() {
344                fs::create_dir_all(parent).unwrap();
345            }
346            fs::write(&path, content).unwrap();
347        }
348        dir
349    }
350
351    fn default_options(app_dir: &Path) -> BuildOptions {
352        BuildOptions {
353            app_dir: app_dir.to_path_buf(),
354            entry: "index.html".to_string(),
355            css: CssStrategy::Link,
356            dom: DomStrategy::Shadow,
357            plugin: None,
358            components: Vec::new(),
359        }
360    }
361
362    #[test]
363    fn test_build_simple_html() {
364        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
365        let result = build(default_options(app.path())).unwrap();
366
367        assert!(result.protocol.fragments.contains_key("index.html"));
368        assert!(result.stats.fragment_count > 0);
369        assert!(result.stats.protocol_size_bytes > 0);
370        assert!(!result.stats.duration.is_zero());
371    }
372
373    #[test]
374    fn test_build_with_directives() {
375        let html = r#"<h1>Hello</h1>
376<for each="item in items">
377    <p>{{item.name}}</p>
378</for>
379<if condition="show">
380    <p>Visible</p>
381</if>"#;
382        let app = create_app_dir(&[("index.html", html)]);
383        let result = build(default_options(app.path())).unwrap();
384
385        let index = &result.protocol.fragments["index.html"].fragments;
386        assert!(index
387            .iter()
388            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
389        assert!(index
390            .iter()
391            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
392    }
393
394    #[test]
395    fn test_build_with_component_css() {
396        let app = create_app_dir(&[
397            ("index.html", "<my-card>Hello</my-card>"),
398            ("my-card.html", "<div><slot></slot></div>"),
399            ("my-card.css", ".card { color: red; }"),
400        ]);
401        let result = build(default_options(app.path())).unwrap();
402
403        assert_eq!(result.css_files.len(), 1);
404        assert_eq!(result.css_files[0].0, "my-card.css");
405        assert!(result.css_files[0].1.contains("color: red"));
406        assert_eq!(result.stats.css_file_count, 1);
407    }
408
409    #[test]
410    fn test_css_href_set_for_light_dom_link_strategy() {
411        let app = create_app_dir(&[
412            ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
413            ("has-css.html", "<p><slot></slot></p>"),
414            ("has-css.css", ".yes { color: green; }"),
415            ("no-css.html", "<p><slot></slot></p>"),
416        ]);
417        let mut options = default_options(app.path());
418        options.dom = DomStrategy::Light;
419        let result = build(options).unwrap();
420
421        let href = result
422            .protocol
423            .components
424            .get("has-css")
425            .map(|c| c.css_href.as_str())
426            .unwrap_or("");
427        assert_eq!(
428            href, "/has-css.css",
429            "Light×Link component with CSS should have css_href"
430        );
431
432        let no_href = result
433            .protocol
434            .components
435            .get("no-css")
436            .map(|c| c.css_href.as_str())
437            .unwrap_or("");
438        assert!(
439            no_href.is_empty(),
440            "Component without CSS should have empty css_href"
441        );
442    }
443
444    #[test]
445    fn test_css_href_empty_for_shadow_dom_link_strategy() {
446        // Shadow×Link: the parser embeds <link> inside each shadow root template,
447        // so css_href must be empty to avoid duplicate <link> tags in <head>.
448        let app = create_app_dir(&[
449            ("index.html", "<my-card>A</my-card>"),
450            ("my-card.html", "<p><slot></slot></p>"),
451            ("my-card.css", ".card { color: red; }"),
452        ]);
453        let result = build(default_options(app.path())).unwrap();
454
455        let href = result
456            .protocol
457            .components
458            .get("my-card")
459            .map(|c| c.css_href.as_str())
460            .unwrap_or("");
461        assert!(
462            href.is_empty(),
463            "Shadow×Link should not set css_href — link is in shadow root"
464        );
465
466        // But CSS file should still be emitted for the server to serve
467        assert_eq!(
468            result.css_files.len(),
469            1,
470            "CSS file should still be produced"
471        );
472    }
473
474    #[test]
475    fn test_css_href_empty_for_style_strategy() {
476        let app = create_app_dir(&[
477            ("index.html", "<my-card>A</my-card>"),
478            ("my-card.html", "<p><slot></slot></p>"),
479            ("my-card.css", ".card { color: red; }"),
480        ]);
481        let mut options = default_options(app.path());
482        options.css = CssStrategy::Style;
483        let result = build(options).unwrap();
484
485        let href = result
486            .protocol
487            .components
488            .get("my-card")
489            .map(|c| c.css_href.as_str())
490            .unwrap_or("");
491        assert!(href.is_empty(), "Style-strategy should not set css_href");
492    }
493
494    #[test]
495    fn test_css_href_empty_for_module_strategy() {
496        let app = create_app_dir(&[
497            ("index.html", "<my-card>A</my-card>"),
498            ("my-card.html", "<p><slot></slot></p>"),
499            ("my-card.css", ".card { color: red; }"),
500        ]);
501        let mut options = default_options(app.path());
502        options.css = CssStrategy::Module;
503        let result = build(options).unwrap();
504
505        let href = result
506            .protocol
507            .components
508            .get("my-card")
509            .map(|c| c.css_href.as_str())
510            .unwrap_or("");
511        assert!(href.is_empty(), "Module-strategy should not set css_href");
512        assert!(
513            !result.protocol.components["my-card"].css.is_empty(),
514            "Module-strategy should still populate css content"
515        );
516    }
517
518    #[test]
519    fn test_build_to_disk_writes_files() {
520        let app = create_app_dir(&[
521            ("index.html", "<my-card>Hello</my-card>"),
522            ("my-card.html", "<div><slot></slot></div>"),
523            ("my-card.css", ".card { color: red; }"),
524        ]);
525        let out = TempDir::new().unwrap();
526
527        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
528
529        assert!(out.path().join("protocol.bin").exists());
530        assert!(out.path().join("my-card.css").exists());
531        assert_eq!(stats.css_file_count, 1);
532        assert!(stats.fragment_count > 0);
533    }
534
535    #[test]
536    fn test_build_to_disk_creates_nested_output_dir() {
537        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
538        let out = TempDir::new().unwrap();
539        let nested = out.path().join("nested").join("output");
540
541        let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
542        assert!(nested.join("protocol.bin").exists());
543        assert!(stats.fragment_count > 0);
544    }
545
546    #[test]
547    fn test_build_missing_entry() {
548        let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
549        let result = build(default_options(app.path()));
550
551        assert!(result.is_err());
552        let err = result.unwrap_err();
553        assert!(matches!(err, WebUIError::Io { .. }));
554    }
555
556    #[test]
557    fn test_build_stats_populated() {
558        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
559        let result = build(default_options(app.path())).unwrap();
560
561        assert!(result.stats.fragment_count > 0);
562        assert!(result.stats.protocol_size_bytes > 0);
563        assert_eq!(result.stats.css_file_count, 0);
564    }
565
566    #[test]
567    fn test_build_inline_css() {
568        let app = create_app_dir(&[
569            ("index.html", "<my-card>Hello</my-card>"),
570            ("my-card.html", "<div><slot></slot></div>"),
571            ("my-card.css", ".card { color: red; }"),
572        ]);
573        let mut options = default_options(app.path());
574        options.css = CssStrategy::Style;
575
576        let result = build(options).unwrap();
577        // Inline mode embeds CSS in <style> tags — no external CSS files
578        assert!(result.css_files.is_empty());
579        assert_eq!(result.stats.css_file_count, 0);
580        assert!(result.stats.fragment_count > 0);
581    }
582
583    #[test]
584    fn test_build_module_css() {
585        let app = create_app_dir(&[
586            ("index.html", "<my-card>Hello</my-card>"),
587            ("my-card.html", "<div><slot></slot></div>"),
588            ("my-card.css", ".card { color: red; }"),
589        ]);
590        let mut options = default_options(app.path());
591        options.css = CssStrategy::Module;
592
593        let result = build(options).unwrap();
594        // Module mode uses <style type="module"> — no external CSS files
595        assert!(result.css_files.is_empty());
596        assert_eq!(result.stats.css_file_count, 0);
597        assert!(result.stats.fragment_count > 0);
598    }
599
600    #[test]
601    fn test_build_custom_entry() {
602        let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
603        let mut options = default_options(app.path());
604        options.entry = "page.html".to_string();
605
606        let result = build(options).unwrap();
607        assert!(result.protocol.fragments.contains_key("page.html"));
608        assert!(!result.protocol.fragments.contains_key("index.html"));
609    }
610
611    #[test]
612    fn test_build_protocol_roundtrip() {
613        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
614        let result = build(default_options(app.path())).unwrap();
615
616        let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
617        assert!(restored.fragments.contains_key("index.html"));
618    }
619
620    #[test]
621    fn test_inspect_bytes_valid() {
622        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
623        let result = build(default_options(app.path())).unwrap();
624
625        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
626        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
627        assert!(parsed.get("fragments").is_some());
628    }
629
630    #[test]
631    fn test_inspect_bytes_invalid() {
632        let result = inspect_bytes(b"not a protobuf");
633        assert!(result.is_err());
634        assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
635    }
636
637    #[test]
638    fn test_inspect_file() {
639        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
640        let out = TempDir::new().unwrap();
641        build_to_disk(default_options(app.path()), out.path()).unwrap();
642
643        let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
644        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
645        assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
646    }
647
648    #[test]
649    fn test_inspect_missing_file() {
650        let result = inspect(Path::new("/nonexistent/protocol.bin"));
651        assert!(result.is_err());
652        assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
653    }
654
655    #[test]
656    fn test_build_with_components_local_path() {
657        let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
658        let ext_dir = TempDir::new().unwrap();
659        fs::write(
660            ext_dir.path().join("ext-card.html"),
661            "<div class=\"card\"><slot></slot></div>",
662        )
663        .unwrap();
664        fs::write(
665            ext_dir.path().join("ext-card.css"),
666            ".card { border: 1px solid #ccc; }",
667        )
668        .unwrap();
669
670        let mut options = default_options(app.path());
671        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
672
673        let result = build(options).unwrap();
674        assert!(result.protocol.fragments.contains_key("index.html"));
675        assert_eq!(result.css_files.len(), 1);
676        assert!(result.css_files[0].1.contains("border"));
677    }
678
679    #[test]
680    fn test_build_hello_world_example() {
681        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
682        let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
683
684        let result = build(BuildOptions {
685            app_dir,
686            entry: "index.html".to_string(),
687            css: CssStrategy::Link,
688            dom: DomStrategy::Shadow,
689            plugin: None,
690            components: Vec::new(),
691        })
692        .unwrap();
693
694        let index = &result.protocol.fragments["index.html"].fragments;
695        assert!(index.iter().any(
696            |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
697        ));
698        assert!(index
699            .iter()
700            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
701    }
702
703    // ── Security tests ───────────────────────────────────────────────
704
705    #[test]
706    fn test_css_filename_sanitizes_path_separators() {
707        // Even if a component tag somehow contains path separators,
708        // the filename should be sanitized to prevent directory traversal.
709        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
710        let result = build(default_options(app.path())).unwrap();
711
712        // Verify all CSS filenames are plain file names (no path separators)
713        for (filename, _) in &result.css_files {
714            assert!(
715                !filename.contains('/') && !filename.contains('\\'),
716                "CSS filename contains path separator: {filename}"
717            );
718        }
719    }
720
721    #[test]
722    fn test_build_to_disk_css_stays_in_output_dir() {
723        let app = create_app_dir(&[
724            ("index.html", "<my-card>Hello</my-card>"),
725            ("my-card.html", "<div><slot></slot></div>"),
726            ("my-card.css", ".card { color: red; }"),
727        ]);
728        let out = TempDir::new().unwrap();
729
730        build_to_disk(default_options(app.path()), out.path()).unwrap();
731
732        // Verify CSS file is written inside out_dir
733        let css_path = out.path().join("my-card.css");
734        assert!(css_path.exists());
735        let canonical = css_path.canonicalize().unwrap();
736        let out_canonical = out.path().canonicalize().unwrap();
737        assert!(
738            canonical.starts_with(&out_canonical),
739            "CSS file escaped output directory"
740        );
741    }
742
743    // ── Performance / edge case tests ────────────────────────────────
744
745    #[test]
746    fn test_build_empty_html() {
747        let app = create_app_dir(&[("index.html", "")]);
748        let result = build(default_options(app.path())).unwrap();
749
750        assert!(result.protocol.fragments.contains_key("index.html"));
751        assert_eq!(result.stats.fragment_count, 0);
752        assert!(result.stats.protocol_size_bytes > 0);
753    }
754
755    #[test]
756    fn test_build_large_fragment_count() {
757        // Verify stats are accurate with many fragments
758        let mut html = String::with_capacity(2000);
759        for i in 0..50 {
760            html.push_str(&format!("<p>Item {i}</p>\n"));
761        }
762        let app = create_app_dir(&[("index.html", &html)]);
763        let result = build(default_options(app.path())).unwrap();
764
765        assert!(result.stats.fragment_count > 0);
766        assert_eq!(result.stats.css_file_count, 0);
767    }
768
769    #[test]
770    fn test_build_with_fast_plugin() {
771        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
772        let mut options = default_options(app.path());
773        options.plugin = Some(Plugin::Fast);
774
775        let result = build(options).unwrap();
776        assert!(result.protocol.fragments.contains_key("index.html"));
777    }
778
779    #[test]
780    fn test_build_to_disk_inline_mode_no_css_files() {
781        let app = create_app_dir(&[
782            ("index.html", "<my-card>Hello</my-card>"),
783            ("my-card.html", "<div><slot></slot></div>"),
784            ("my-card.css", ".card { color: red; }"),
785        ]);
786        let out = TempDir::new().unwrap();
787        let mut options = default_options(app.path());
788        options.css = CssStrategy::Style;
789
790        let stats = build_to_disk(options, out.path()).unwrap();
791
792        assert!(out.path().join("protocol.bin").exists());
793        assert!(!out.path().join("my-card.css").exists());
794        assert_eq!(stats.css_file_count, 0);
795    }
796
797    #[test]
798    fn test_build_stats_duration_is_nonzero() {
799        let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
800        let result = build(default_options(app.path())).unwrap();
801
802        assert!(!result.stats.duration.is_zero());
803    }
804
805    #[test]
806    fn test_build_multiple_components_css() {
807        let app = create_app_dir(&[
808            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
809            ("card-a.html", "<div><slot></slot></div>"),
810            ("card-a.css", ".a { color: red; }"),
811            ("card-b.html", "<span><slot></slot></span>"),
812            ("card-b.css", ".b { color: blue; }"),
813        ]);
814        let result = build(default_options(app.path())).unwrap();
815
816        assert_eq!(result.css_files.len(), 2);
817        assert_eq!(result.stats.css_file_count, 2);
818        let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
819        assert!(filenames.contains(&"card-a.css"));
820        assert!(filenames.contains(&"card-b.css"));
821    }
822
823    #[test]
824    fn test_build_unused_component_css_not_emitted() {
825        // card-b is registered but not referenced in index.html
826        let app = create_app_dir(&[
827            ("index.html", "<card-a>A</card-a>"),
828            ("card-a.html", "<div><slot></slot></div>"),
829            ("card-a.css", ".a { color: red; }"),
830            ("card-b.html", "<span><slot></slot></span>"),
831            ("card-b.css", ".b { color: blue; }"),
832        ]);
833        let result = build(default_options(app.path())).unwrap();
834
835        assert_eq!(result.css_files.len(), 1);
836        assert_eq!(result.css_files[0].0, "card-a.css");
837    }
838
839    #[test]
840    fn test_inspect_roundtrip_preserves_content() {
841        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
842        let result = build(default_options(app.path())).unwrap();
843
844        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
845        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
846
847        let fragments = &parsed["fragments"]["index.html"]["fragments"];
848        assert!(fragments.is_array());
849        assert!(!fragments.as_array().unwrap().is_empty());
850    }
851
852    #[test]
853    fn test_build_nonexistent_app_dir() {
854        let options = BuildOptions {
855            app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
856            entry: "index.html".to_string(),
857            css: CssStrategy::Link,
858            dom: DomStrategy::Shadow,
859            plugin: None,
860            components: Vec::new(),
861        };
862        let result = build(options);
863        assert!(result.is_err());
864    }
865
866    #[test]
867    fn test_build_to_disk_returns_accurate_stats() {
868        let app = create_app_dir(&[
869            ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
870            ("my-card.html", "<div><slot></slot></div>"),
871            ("my-card.css", ".card { color: red; }"),
872        ]);
873        let out = TempDir::new().unwrap();
874
875        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
876
877        assert!(stats.fragment_count > 0);
878        assert_eq!(stats.css_file_count, 1);
879        assert!(stats.component_count > 0);
880        assert!(stats.protocol_size_bytes > 0);
881        assert!(!stats.duration.is_zero());
882    }
883}