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