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