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