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