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};
13//! use std::path::PathBuf;
14//!
15//! let result = build(BuildOptions {
16//!     app_dir: PathBuf::from("./src"),
17//!     ..BuildOptions::default()
18//! }).unwrap();
19//!
20//! println!("Built {} fragments in {:?}", result.stats.fragment_count, result.stats.duration);
21//! ```
22
23mod error;
24pub mod server;
25pub mod streaming;
26
27pub use error::WebUIError;
28
29// Re-export core types from downstream crates
30pub use webui_handler::route_handler::{
31    encode_inventory, get_needed_components, get_needed_components_for_request, parse_inventory,
32    ProtocolIndex,
33};
34pub use webui_handler::route_matcher::CompiledRouteCache;
35pub use webui_handler::Result as HandlerResult;
36pub use webui_handler::{
37    plugin::HandlerPlugin, HandlerError, RenderOptions, ResponseWriter, WebUIHandler,
38};
39pub use webui_parser::CssStrategy;
40pub use webui_parser::DomStrategy;
41pub use webui_parser::LegalComments;
42pub use webui_parser::ParserOptions;
43pub use webui_parser::Plugin;
44pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
45pub use webui_protocol::WebUIProtocol;
46
47use std::collections::HashSet;
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    /// Link-mode CSS filename template using `[name]`, `[hash]`, and `[ext]`.
73    /// Ignored unless `css == CssStrategy::Link`.
74    pub css_file_name_template: String,
75    /// Optional URL/base-path prefix for Link-mode `css_href` values.
76    /// When set, emitted protocol hrefs become `<base>/<filename>`.
77    pub css_public_base: Option<String>,
78    /// Legal comment preservation strategy.
79    pub legal_comments: LegalComments,
80}
81
82impl Default for BuildOptions {
83    fn default() -> Self {
84        Self {
85            app_dir: std::path::PathBuf::from("."),
86            entry: "index.html".to_string(),
87            css: CssStrategy::Link,
88            dom: DomStrategy::Shadow,
89            plugin: None,
90            components: Vec::new(),
91            css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
92            css_public_base: None,
93            legal_comments: LegalComments::default(),
94        }
95    }
96}
97
98/// Statistics about a completed build.
99#[derive(Debug, Clone)]
100pub struct BuildStats {
101    /// Total wall-clock build time.
102    pub duration: std::time::Duration,
103    /// Total number of protocol fragments.
104    pub fragment_count: usize,
105    /// Number of registered components.
106    pub component_count: usize,
107    /// Number of CSS files produced.
108    pub css_file_count: usize,
109    /// Size of the serialized protocol in bytes.
110    pub protocol_size_bytes: usize,
111    /// Number of unique CSS tokens discovered.
112    pub token_count: usize,
113}
114
115/// Result of a successful build.
116#[derive(Debug)]
117pub struct BuildResult {
118    /// The compiled WebUI protocol.
119    pub protocol: WebUIProtocol,
120    /// Serialized protocol bytes (protobuf binary).
121    pub protocol_bytes: Vec<u8>,
122    /// Component CSS files: `(filename, content)` — only components referenced in the protocol.
123    pub css_files: Vec<(String, String)>,
124    /// Component f-template strings: `(tag_name, f_template_html)`.
125    /// Includes templates for all components encountered during parsing,
126    /// including route-referenced components.
127    pub component_templates: Vec<(String, String)>,
128    /// Build statistics.
129    pub stats: BuildStats,
130}
131
132/// Build a WebUI application from an app directory.
133///
134/// Parses templates, discovers components, and produces a compiled protocol
135/// with build statistics.
136///
137/// # Errors
138///
139/// Returns [`WebUIError`] if the app directory is invalid, templates fail
140/// to parse, or the protocol cannot be serialized.
141#[must_use = "BuildResult contains the compiled protocol and statistics"]
142pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
143    let started = Instant::now();
144
145    let raw = build_protocol_inner(&options)?;
146
147    let protocol_bytes = raw.protocol.to_protobuf()?;
148
149    let stats = BuildStats {
150        duration: started.elapsed(),
151        fragment_count: raw.fragment_count,
152        component_count: raw.component_count,
153        css_file_count: raw.css_files.len(),
154        protocol_size_bytes: protocol_bytes.len(),
155        token_count: raw.token_count,
156    };
157
158    Ok(BuildResult {
159        protocol: raw.protocol,
160        protocol_bytes,
161        css_files: raw.css_files,
162        component_templates: raw.component_templates,
163        stats,
164    })
165}
166
167/// Build a WebUI application and write output files to disk.
168///
169/// Writes `protocol.bin` and any external CSS files to `out_dir`.
170/// Creates `out_dir` if it does not exist.
171///
172/// # Errors
173///
174/// Returns [`WebUIError`] on build failure or if output files cannot be written.
175pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
176    let result = build(options)?;
177
178    fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
179        context: format!("Failed to create {}", out_dir.display()),
180        source,
181    })?;
182
183    fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
184        WebUIError::Io {
185            context: format!("Failed to write protocol.bin to {}", out_dir.display()),
186            source,
187        }
188    })?;
189
190    for (name, content) in &result.css_files {
191        fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
192            context: format!("Failed to write {name} to {}", out_dir.display()),
193            source,
194        })?;
195    }
196
197    Ok(result.stats)
198}
199
200/// Inspect a compiled WebUI protocol file and return its JSON representation.
201pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
202    let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
203        context: format!("Failed to read {}", protocol_path.display()),
204        source,
205    })?;
206    inspect_bytes(&bytes)
207}
208
209/// Inspect raw protocol bytes and return their JSON representation.
210pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
211    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
212    protocol
213        .to_json_pretty()
214        .map_err(|e| WebUIError::Serialization(e.to_string()))
215}
216
217/// Internal intermediate build output before stats are computed.
218struct RawBuildOutput {
219    protocol: WebUIProtocol,
220    css_files: Vec<(String, String)>,
221    component_templates: Vec<(String, String)>,
222    fragment_count: usize,
223    component_count: usize,
224    token_count: usize,
225}
226
227/// Internal build logic shared by `build()` and `build_to_disk()`.
228fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
229    let parser_options = ParserOptions::try_new(
230        options.css,
231        options.dom,
232        &options.css_file_name_template,
233        options.css_public_base.as_deref(),
234        options.legal_comments,
235    )
236    .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
237    let css_link_options = parser_options.css_link_options.clone();
238
239    let mut parser = match options.plugin {
240        Some(Plugin::Fast | Plugin::FastV2) => {
241            HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
242        }
243        Some(Plugin::FastV3) => {
244            HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
245        }
246        Some(Plugin::WebUI) => {
247            HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
248        }
249        None => HtmlParser::with_options(parser_options),
250    };
251
252    // Register app directory components
253    parser
254        .component_registry_mut()
255        .register_from_paths(&[&options.app_dir])
256        .map_err(|e| {
257            WebUIError::ComponentRegistration(format!(
258                "Failed to register components from {}: {e}",
259                options.app_dir.display()
260            ))
261        })?;
262
263    // Discover and register external component sources
264    for source in &options.components {
265        let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
266            WebUIError::ComponentDiscovery(format!(
267                "Failed to discover components from {source}: {e}"
268            ))
269        })?;
270        for comp in &result.components {
271            parser
272                .component_registry_mut()
273                .register_component(
274                    &comp.tag_name,
275                    &comp.html_content,
276                    comp.css_content.as_deref(),
277                )
278                .map_err(|e| {
279                    WebUIError::ComponentRegistration(format!(
280                        "Failed to register component '{}' from {}: {e}",
281                        comp.tag_name, comp.source
282                    ))
283                })?;
284        }
285    }
286
287    let component_count = parser.component_registry().len();
288
289    // Parse entry HTML
290    let entry_path = options.app_dir.join(&options.entry);
291    let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
292        context: format!("Failed to read {}", entry_path.display()),
293        source,
294    })?;
295    parser
296        .parse(&options.entry, &html_content)
297        .map_err(|source| WebUIError::Parse {
298            context: format!("Failed to parse {}", options.entry),
299            source,
300        })?;
301
302    let css_snapshot: Vec<(String, String)> = parser
303        .component_registry()
304        .get_all()
305        .filter(|component| parser.has_fragment(&component.tag_name))
306        .filter_map(|component| {
307            component
308                .css_content
309                .as_ref()
310                .map(|css| (component.tag_name.clone(), css.clone()))
311        })
312        .collect();
313
314    // Collect CSS tokens before consuming the parser
315    let tokens = parser.take_tokens();
316    let token_count = tokens.len();
317
318    let component_templates = match parser.take_plugin_artifacts() {
319        ParserPluginArtifacts::None => Vec::new(),
320        ParserPluginArtifacts::ComponentTemplates(templates) => templates,
321    };
322
323    // Build protocol (consumes parser)
324    let fragment_records = parser.into_fragment_records();
325    let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
326
327    let mut protocol = WebUIProtocol::with_tokens(fragment_records, tokens);
328
329    // Record build-wide strategies so the handler can decide rendering behavior.
330    protocol.set_css_strategy(match options.css {
331        CssStrategy::Link => webui_protocol::CssStrategy::Link,
332        CssStrategy::Style => webui_protocol::CssStrategy::Style,
333        CssStrategy::Module => webui_protocol::CssStrategy::Module,
334    });
335    protocol.set_dom_strategy(match options.dom {
336        DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
337        DomStrategy::Light => webui_protocol::DomStrategy::Light,
338    });
339
340    // Process component CSS in a single pass: store Module CSS content,
341    // set Link-strategy css_href, and collect external CSS files.
342    let is_module = options.css == CssStrategy::Module;
343    let is_link = options.css == CssStrategy::Link;
344    let mut css_files: Vec<(String, String)> = Vec::new();
345    let mut emitted_names: HashSet<String> = HashSet::new();
346    for (tag, css) in css_snapshot {
347        if !protocol.fragments.contains_key(&tag) {
348            continue;
349        }
350        if is_module {
351            protocol.components.entry(tag).or_default().css = css.trim().to_string();
352        } else if is_link {
353            let resolved = css_link_options.resolve(&tag, &css);
354            if !emitted_names.insert(resolved.filename.clone()) {
355                return Err(WebUIError::InvalidBuildOptions(format!(
356                    "CSS filename collision for Link strategy: '{}'. Adjust --css-file-name-template to include [name] or another unique component-specific segment.",
357                    resolved.filename
358                )));
359            }
360            protocol.components.entry(tag).or_default().css_href = resolved.href;
361            css_files.push((resolved.filename, css));
362        }
363        // Style strategy: CSS is already baked into raw fragments by the
364        // parser — nothing to store in the protocol or emit as files.
365    }
366
367    // Store client templates in the protocol so any host server can query them
368    for (tag, tmpl) in &component_templates {
369        protocol.components.entry(tag.clone()).or_default().template = tmpl.clone();
370    }
371
372    Ok(RawBuildOutput {
373        protocol,
374        css_files,
375        component_templates,
376        fragment_count,
377        component_count,
378        token_count,
379    })
380}
381
382#[cfg(test)]
383#[allow(clippy::disallowed_methods)]
384mod tests {
385    use super::*;
386    use std::path::PathBuf;
387    use tempfile::TempDir;
388    use webui_protocol::web_ui_fragment::Fragment;
389
390    struct StringWriter {
391        buf: String,
392    }
393
394    impl ResponseWriter for StringWriter {
395        fn write(&mut self, content: &str) -> webui_handler::Result<()> {
396            self.buf.push_str(content);
397            Ok(())
398        }
399
400        fn end(&mut self) -> webui_handler::Result<()> {
401            Ok(())
402        }
403    }
404
405    fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
406        let dir = TempDir::new().unwrap();
407        for (name, content) in files {
408            let path = dir.path().join(name);
409            if let Some(parent) = path.parent() {
410                fs::create_dir_all(parent).unwrap();
411            }
412            fs::write(&path, content).unwrap();
413        }
414        dir
415    }
416
417    fn default_options(app_dir: &Path) -> BuildOptions {
418        BuildOptions {
419            app_dir: app_dir.to_path_buf(),
420            ..BuildOptions::default()
421        }
422    }
423
424    #[test]
425    fn test_build_simple_html() {
426        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
427        let result = build(default_options(app.path())).unwrap();
428
429        assert!(result.protocol.fragments.contains_key("index.html"));
430        assert!(result.stats.fragment_count > 0);
431        assert!(result.stats.protocol_size_bytes > 0);
432        assert!(!result.stats.duration.is_zero());
433    }
434
435    #[test]
436    fn test_build_with_directives() {
437        let html = r#"<h1>Hello</h1>
438<for each="item in items">
439    <p>{{item.name}}</p>
440</for>
441<if condition="show">
442    <p>Visible</p>
443</if>"#;
444        let app = create_app_dir(&[("index.html", html)]);
445        let result = build(default_options(app.path())).unwrap();
446
447        let index = &result.protocol.fragments["index.html"].fragments;
448        assert!(index
449            .iter()
450            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
451        assert!(index
452            .iter()
453            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
454    }
455
456    #[test]
457    fn test_build_with_component_css() {
458        let app = create_app_dir(&[
459            ("index.html", "<my-card>Hello</my-card>"),
460            ("my-card.html", "<div><slot></slot></div>"),
461            ("my-card.css", ".card { color: red; }"),
462        ]);
463        let result = build(default_options(app.path())).unwrap();
464
465        assert_eq!(result.css_files.len(), 1);
466        assert_eq!(result.css_files[0].0, "my-card.css");
467        assert!(result.css_files[0].1.contains("color: red"));
468        assert_eq!(result.stats.css_file_count, 1);
469    }
470
471    #[test]
472    fn test_build_strips_non_legal_css_comments_from_output_file() {
473        let app = create_app_dir(&[
474            ("index.html", "<my-card>Hello</my-card>"),
475            ("my-card.html", "<div><slot></slot></div>"),
476            (
477                "my-card.css",
478                "/* remove var(--ignored) */ .card { color: var(--textColor); }",
479            ),
480        ]);
481        let result = build(default_options(app.path())).unwrap();
482
483        assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
484        assert_eq!(result.protocol.tokens, vec!["textColor"]);
485    }
486
487    #[test]
488    fn test_build_preserves_legal_css_comments_by_default() {
489        let app = create_app_dir(&[
490            ("index.html", "<my-card>Hello</my-card>"),
491            ("my-card.html", "<div><slot></slot></div>"),
492            (
493                "my-card.css",
494                "/*! @license MIT */ .card { color: red; } /* remove */",
495            ),
496        ]);
497        let result = build(default_options(app.path())).unwrap();
498
499        assert_eq!(
500            result.css_files[0].1,
501            "/*! @license MIT */ .card { color: red; } "
502        );
503    }
504
505    #[test]
506    fn test_build_legal_comments_none_strips_legal_css_comments() {
507        let app = create_app_dir(&[
508            ("index.html", "<my-card>Hello</my-card>"),
509            ("my-card.html", "<div><slot></slot></div>"),
510            ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
511        ]);
512        let mut options = default_options(app.path());
513        options.legal_comments = LegalComments::None;
514        let result = build(options).unwrap();
515
516        assert_eq!(result.css_files[0].1, " .card { color: red; }");
517    }
518
519    #[test]
520    fn test_build_with_css_hashed_filename_template() {
521        let app = create_app_dir(&[
522            ("index.html", "<my-card>Hello</my-card>"),
523            ("my-card.html", "<div><slot></slot></div>"),
524            ("my-card.css", ".card { color: red; }"),
525        ]);
526        let mut options = default_options(app.path());
527        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
528        let result = build(options).unwrap();
529
530        assert_eq!(result.css_files.len(), 1);
531        let generated = &result.css_files[0].0;
532        assert!(generated.starts_with("my-card-"));
533        assert!(generated.ends_with(".css"));
534        assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
535        assert_eq!(
536            result
537                .protocol
538                .components
539                .get("my-card")
540                .map(|c| c.css_href.as_str()),
541            Some(generated.as_str())
542        );
543    }
544
545    #[test]
546    fn test_css_public_base_prefixes_css_href_only() {
547        let app = create_app_dir(&[
548            ("index.html", "<my-card>Hello</my-card>"),
549            ("my-card.html", "<div><slot></slot></div>"),
550            ("my-card.css", ".card { color: red; }"),
551        ]);
552        let mut options = default_options(app.path());
553        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
554        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
555        let result = build(options).unwrap();
556
557        let filename = &result.css_files[0].0;
558        assert!(filename.starts_with("my-card-"));
559        let expected_href = format!("https://cdn.example.com/assets/{filename}");
560        assert_eq!(
561            result
562                .protocol
563                .components
564                .get("my-card")
565                .map(|c| c.css_href.as_str()),
566            Some(expected_href.as_str())
567        );
568    }
569
570    #[test]
571    fn test_css_public_base_emits_parser_and_handler_links() {
572        let app = create_app_dir(&[
573            (
574                "index.html",
575                "<html><head></head><body><my-card>Hello</my-card></body></html>",
576            ),
577            ("my-card.html", "<div><slot></slot></div>"),
578            ("my-card.css", ".card { color: red; }"),
579        ]);
580        let mut options = default_options(app.path());
581        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
582        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
583        let result = build(options).unwrap();
584
585        let filename = &result.css_files[0].0;
586        let expected_href = format!("https://cdn.example.com/assets/{filename}");
587        let component_html = result.protocol.fragments["my-card"]
588            .fragments
589            .iter()
590            .filter_map(|fragment| match fragment.fragment.as_ref() {
591                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
592                _ => None,
593            })
594            .collect::<String>();
595        assert!(
596            component_html.contains(&format!(
597                r#"<link rel="stylesheet" href="{expected_href}">"#
598            )),
599            "parser-generated component template should use CDN href: {component_html}"
600        );
601
602        let handler = WebUIHandler::new();
603        let mut writer = StringWriter { buf: String::new() };
604        handler
605            .handle(
606                &result.protocol,
607                &serde_json::json!({}),
608                &RenderOptions::new("index.html", "/"),
609                &mut writer,
610            )
611            .unwrap();
612        assert!(
613            writer.buf.contains(&format!(
614                r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
615            )),
616            "handler head preload should use CDN href: {}",
617            writer.buf
618        );
619    }
620
621    #[test]
622    fn test_css_public_base_emits_webui_plugin_template_links() {
623        let app = create_app_dir(&[
624            ("index.html", "<my-card>Hello</my-card>"),
625            ("my-card.html", "<div><slot></slot></div>"),
626            ("my-card.css", ".card { color: red; }"),
627        ]);
628        let mut options = default_options(app.path());
629        options.plugin = Some(Plugin::WebUI);
630        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
631        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
632        let result = build(options).unwrap();
633
634        let filename = &result.css_files[0].0;
635        let expected_href = format!("https://cdn.example.com/assets/{filename}");
636        let template = &result.protocol.components["my-card"].template;
637        assert!(
638            template.contains(&format!(r#"href=\"{expected_href}\""#)),
639            "plugin component template should use CDN href: {template}"
640        );
641    }
642
643    #[test]
644    fn test_css_public_base_emits_fast_plugin_template_links() {
645        let app = create_app_dir(&[
646            ("index.html", "<my-card>Hello</my-card>"),
647            ("my-card.html", "<div><slot></slot></div>"),
648            ("my-card.css", ".card { color: red; }"),
649        ]);
650        let mut options = default_options(app.path());
651        options.plugin = Some(Plugin::FastV3);
652        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
653        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
654        let result = build(options).unwrap();
655
656        let filename = &result.css_files[0].0;
657        let expected_href = format!("https://cdn.example.com/assets/{filename}");
658        let template = &result.protocol.components["my-card"].template;
659        assert!(
660            template.contains(&format!(r#"href="{expected_href}""#)),
661            "FAST component template should use CDN href: {template}"
662        );
663    }
664
665    #[test]
666    fn test_invalid_css_template_is_rejected() {
667        let app = create_app_dir(&[
668            ("index.html", "<my-card>Hello</my-card>"),
669            ("my-card.html", "<div><slot></slot></div>"),
670            ("my-card.css", ".card { color: red; }"),
671        ]);
672        let mut options = default_options(app.path());
673        options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
674        let result = build(options);
675
676        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
677    }
678
679    #[test]
680    fn test_css_filename_collision_is_rejected() {
681        let app = create_app_dir(&[
682            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
683            ("card-a.html", "<div><slot></slot></div>"),
684            ("card-a.css", ".x { color: red; }"),
685            ("card-b.html", "<div><slot></slot></div>"),
686            ("card-b.css", ".x { color: red; }"),
687        ]);
688        let mut options = default_options(app.path());
689        options.css_file_name_template = "[hash].[ext]".to_string();
690        let result = build(options);
691
692        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
693    }
694
695    #[test]
696    fn test_css_href_set_for_light_dom_link_strategy() {
697        let app = create_app_dir(&[
698            ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
699            ("has-css.html", "<p><slot></slot></p>"),
700            ("has-css.css", ".yes { color: green; }"),
701            ("no-css.html", "<p><slot></slot></p>"),
702        ]);
703        let mut options = default_options(app.path());
704        options.dom = DomStrategy::Light;
705        let result = build(options).unwrap();
706
707        let href = result
708            .protocol
709            .components
710            .get("has-css")
711            .map(|c| c.css_href.as_str())
712            .unwrap_or("");
713        assert_eq!(
714            href, "has-css.css",
715            "Light×Link component with CSS should have css_href"
716        );
717
718        let no_href = result
719            .protocol
720            .components
721            .get("no-css")
722            .map(|c| c.css_href.as_str())
723            .unwrap_or("");
724        assert!(
725            no_href.is_empty(),
726            "Component without CSS should have empty css_href"
727        );
728    }
729
730    #[test]
731    fn test_shadow_dom_link_strategy_sets_css_href() {
732        // Shadow×Link: css_href is always set for Link-strategy components.
733        // The handler uses protocol.css_strategy + dom_strategy to decide
734        // whether to emit preload (Shadow) or stylesheet (Light) in <head>.
735        let app = create_app_dir(&[
736            ("index.html", "<my-card>A</my-card>"),
737            ("my-card.html", "<p><slot></slot></p>"),
738            ("my-card.css", ".card { color: red; }"),
739        ]);
740        let result = build(default_options(app.path())).unwrap();
741
742        let comp = result.protocol.components.get("my-card").unwrap();
743        assert_eq!(
744            comp.css_href, "my-card.css",
745            "Shadow×Link should set css_href"
746        );
747
748        // Strategy fields on protocol
749        assert_eq!(
750            result.protocol.css_strategy(),
751            webui_protocol::CssStrategy::Link,
752        );
753        assert_eq!(
754            result.protocol.dom_strategy(),
755            webui_protocol::DomStrategy::Shadow,
756        );
757
758        // CSS file should still be emitted for the server to serve
759        assert_eq!(
760            result.css_files.len(),
761            1,
762            "CSS file should still be produced"
763        );
764    }
765
766    #[test]
767    fn test_css_href_empty_for_style_strategy() {
768        let app = create_app_dir(&[
769            ("index.html", "<my-card>A</my-card>"),
770            ("my-card.html", "<p><slot></slot></p>"),
771            ("my-card.css", ".card { color: red; }"),
772        ]);
773        let mut options = default_options(app.path());
774        options.css = CssStrategy::Style;
775        let result = build(options).unwrap();
776
777        let href = result
778            .protocol
779            .components
780            .get("my-card")
781            .map(|c| c.css_href.as_str())
782            .unwrap_or("");
783        assert!(href.is_empty(), "Style-strategy should not set css_href");
784    }
785
786    #[test]
787    fn test_css_href_empty_for_module_strategy() {
788        let app = create_app_dir(&[
789            ("index.html", "<my-card>A</my-card>"),
790            ("my-card.html", "<p><slot></slot></p>"),
791            ("my-card.css", ".card { color: red; }"),
792        ]);
793        let mut options = default_options(app.path());
794        options.css = CssStrategy::Module;
795        let result = build(options).unwrap();
796
797        let href = result
798            .protocol
799            .components
800            .get("my-card")
801            .map(|c| c.css_href.as_str())
802            .unwrap_or("");
803        assert!(href.is_empty(), "Module-strategy should not set css_href");
804        assert!(
805            !result.protocol.components["my-card"].css.is_empty(),
806            "Module-strategy should still populate css content"
807        );
808    }
809
810    #[test]
811    fn test_build_to_disk_writes_files() {
812        let app = create_app_dir(&[
813            ("index.html", "<my-card>Hello</my-card>"),
814            ("my-card.html", "<div><slot></slot></div>"),
815            ("my-card.css", ".card { color: red; }"),
816        ]);
817        let out = TempDir::new().unwrap();
818
819        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
820
821        assert!(out.path().join("protocol.bin").exists());
822        assert!(out.path().join("my-card.css").exists());
823        assert_eq!(stats.css_file_count, 1);
824        assert!(stats.fragment_count > 0);
825    }
826
827    #[test]
828    fn test_build_to_disk_creates_nested_output_dir() {
829        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
830        let out = TempDir::new().unwrap();
831        let nested = out.path().join("nested").join("output");
832
833        let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
834        assert!(nested.join("protocol.bin").exists());
835        assert!(stats.fragment_count > 0);
836    }
837
838    #[test]
839    fn test_build_missing_entry() {
840        let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
841        let result = build(default_options(app.path()));
842
843        assert!(result.is_err());
844        let err = result.unwrap_err();
845        assert!(matches!(err, WebUIError::Io { .. }));
846    }
847
848    #[test]
849    fn test_build_stats_populated() {
850        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
851        let result = build(default_options(app.path())).unwrap();
852
853        assert!(result.stats.fragment_count > 0);
854        assert!(result.stats.protocol_size_bytes > 0);
855        assert_eq!(result.stats.css_file_count, 0);
856    }
857
858    #[test]
859    fn test_build_inline_css() {
860        let app = create_app_dir(&[
861            ("index.html", "<my-card>Hello</my-card>"),
862            ("my-card.html", "<div><slot></slot></div>"),
863            ("my-card.css", ".card { color: red; }"),
864        ]);
865        let mut options = default_options(app.path());
866        options.css = CssStrategy::Style;
867
868        let result = build(options).unwrap();
869        // Inline mode embeds CSS in <style> tags — no external CSS files
870        assert!(result.css_files.is_empty());
871        assert_eq!(result.stats.css_file_count, 0);
872        assert!(result.stats.fragment_count > 0);
873    }
874
875    #[test]
876    fn test_build_module_css() {
877        let app = create_app_dir(&[
878            ("index.html", "<my-card>Hello</my-card>"),
879            ("my-card.html", "<div><slot></slot></div>"),
880            ("my-card.css", ".card { color: red; }"),
881        ]);
882        let mut options = default_options(app.path());
883        options.css = CssStrategy::Module;
884
885        let result = build(options).unwrap();
886        // Module mode emits <script type="importmap"> data URIs — no external CSS files
887        assert!(result.css_files.is_empty());
888        assert_eq!(result.stats.css_file_count, 0);
889        assert!(result.stats.fragment_count > 0);
890    }
891
892    #[test]
893    fn test_build_custom_entry() {
894        let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
895        let mut options = default_options(app.path());
896        options.entry = "page.html".to_string();
897
898        let result = build(options).unwrap();
899        assert!(result.protocol.fragments.contains_key("page.html"));
900        assert!(!result.protocol.fragments.contains_key("index.html"));
901    }
902
903    #[test]
904    fn test_build_protocol_roundtrip() {
905        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
906        let result = build(default_options(app.path())).unwrap();
907
908        let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
909        assert!(restored.fragments.contains_key("index.html"));
910    }
911
912    #[test]
913    fn test_inspect_bytes_valid() {
914        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
915        let result = build(default_options(app.path())).unwrap();
916
917        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
918        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
919        assert!(parsed.get("fragments").is_some());
920    }
921
922    #[test]
923    fn test_inspect_bytes_invalid() {
924        let result = inspect_bytes(b"not a protobuf");
925        assert!(result.is_err());
926        assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
927    }
928
929    #[test]
930    fn test_inspect_file() {
931        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
932        let out = TempDir::new().unwrap();
933        build_to_disk(default_options(app.path()), out.path()).unwrap();
934
935        let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
936        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
937        assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
938    }
939
940    #[test]
941    fn test_inspect_missing_file() {
942        let result = inspect(Path::new("/nonexistent/protocol.bin"));
943        assert!(result.is_err());
944        assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
945    }
946
947    #[test]
948    fn test_build_with_components_local_path() {
949        let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
950        let ext_dir = TempDir::new().unwrap();
951        fs::write(
952            ext_dir.path().join("ext-card.html"),
953            "<div class=\"card\"><slot></slot></div>",
954        )
955        .unwrap();
956        fs::write(
957            ext_dir.path().join("ext-card.css"),
958            ".card { border: 1px solid #ccc; }",
959        )
960        .unwrap();
961
962        let mut options = default_options(app.path());
963        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
964
965        let result = build(options).unwrap();
966        assert!(result.protocol.fragments.contains_key("index.html"));
967        assert_eq!(result.css_files.len(), 1);
968        assert!(result.css_files[0].1.contains("border"));
969    }
970
971    #[test]
972    fn test_build_hello_world_example() {
973        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
974        let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
975
976        let result = build(BuildOptions {
977            app_dir,
978            entry: "index.html".to_string(),
979            ..BuildOptions::default()
980        })
981        .unwrap();
982
983        let index = &result.protocol.fragments["index.html"].fragments;
984        assert!(index.iter().any(
985            |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
986        ));
987        assert!(index
988            .iter()
989            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
990    }
991
992    // ── Security tests ───────────────────────────────────────────────
993
994    #[test]
995    fn test_css_filename_sanitizes_path_separators() {
996        // Even if a component tag somehow contains path separators,
997        // the filename should be sanitized to prevent directory traversal.
998        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
999        let result = build(default_options(app.path())).unwrap();
1000
1001        // Verify all CSS filenames are plain file names (no path separators)
1002        for (filename, _) in &result.css_files {
1003            assert!(
1004                !filename.contains('/') && !filename.contains('\\'),
1005                "CSS filename contains path separator: {filename}"
1006            );
1007        }
1008    }
1009
1010    #[test]
1011    fn test_build_to_disk_css_stays_in_output_dir() {
1012        let app = create_app_dir(&[
1013            ("index.html", "<my-card>Hello</my-card>"),
1014            ("my-card.html", "<div><slot></slot></div>"),
1015            ("my-card.css", ".card { color: red; }"),
1016        ]);
1017        let out = TempDir::new().unwrap();
1018
1019        build_to_disk(default_options(app.path()), out.path()).unwrap();
1020
1021        // Verify CSS file is written inside out_dir
1022        let css_path = out.path().join("my-card.css");
1023        assert!(css_path.exists());
1024        let canonical = css_path.canonicalize().unwrap();
1025        let out_canonical = out.path().canonicalize().unwrap();
1026        assert!(
1027            canonical.starts_with(&out_canonical),
1028            "CSS file escaped output directory"
1029        );
1030    }
1031
1032    // ── Performance / edge case tests ────────────────────────────────
1033
1034    #[test]
1035    fn test_build_empty_html() {
1036        let app = create_app_dir(&[("index.html", "")]);
1037        let result = build(default_options(app.path())).unwrap();
1038
1039        assert!(result.protocol.fragments.contains_key("index.html"));
1040        assert_eq!(result.stats.fragment_count, 0);
1041        assert!(result.stats.protocol_size_bytes > 0);
1042    }
1043
1044    #[test]
1045    fn test_build_large_fragment_count() {
1046        // Verify stats are accurate with many fragments
1047        let mut html = String::with_capacity(2000);
1048        for i in 0..50 {
1049            html.push_str(&format!("<p>Item {i}</p>\n"));
1050        }
1051        let app = create_app_dir(&[("index.html", &html)]);
1052        let result = build(default_options(app.path())).unwrap();
1053
1054        assert!(result.stats.fragment_count > 0);
1055        assert_eq!(result.stats.css_file_count, 0);
1056    }
1057
1058    #[test]
1059    fn test_build_with_fast_plugin() {
1060        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1061        let mut options = default_options(app.path());
1062        options.plugin = Some(Plugin::FastV3);
1063
1064        let result = build(options).unwrap();
1065        assert!(result.protocol.fragments.contains_key("index.html"));
1066    }
1067
1068    #[test]
1069    fn test_build_to_disk_inline_mode_no_css_files() {
1070        let app = create_app_dir(&[
1071            ("index.html", "<my-card>Hello</my-card>"),
1072            ("my-card.html", "<div><slot></slot></div>"),
1073            ("my-card.css", ".card { color: red; }"),
1074        ]);
1075        let out = TempDir::new().unwrap();
1076        let mut options = default_options(app.path());
1077        options.css = CssStrategy::Style;
1078
1079        let stats = build_to_disk(options, out.path()).unwrap();
1080
1081        assert!(out.path().join("protocol.bin").exists());
1082        assert!(!out.path().join("my-card.css").exists());
1083        assert_eq!(stats.css_file_count, 0);
1084    }
1085
1086    #[test]
1087    fn test_build_stats_duration_is_nonzero() {
1088        let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
1089        let result = build(default_options(app.path())).unwrap();
1090
1091        assert!(!result.stats.duration.is_zero());
1092    }
1093
1094    #[test]
1095    fn test_build_multiple_components_css() {
1096        let app = create_app_dir(&[
1097            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1098            ("card-a.html", "<div><slot></slot></div>"),
1099            ("card-a.css", ".a { color: red; }"),
1100            ("card-b.html", "<span><slot></slot></span>"),
1101            ("card-b.css", ".b { color: blue; }"),
1102        ]);
1103        let result = build(default_options(app.path())).unwrap();
1104
1105        assert_eq!(result.css_files.len(), 2);
1106        assert_eq!(result.stats.css_file_count, 2);
1107        let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
1108        assert!(filenames.contains(&"card-a.css"));
1109        assert!(filenames.contains(&"card-b.css"));
1110    }
1111
1112    #[test]
1113    fn test_build_unused_component_css_not_emitted() {
1114        // card-b is registered but not referenced in index.html
1115        let app = create_app_dir(&[
1116            ("index.html", "<card-a>A</card-a>"),
1117            ("card-a.html", "<div><slot></slot></div>"),
1118            ("card-a.css", ".a { color: red; }"),
1119            ("card-b.html", "<span><slot></slot></span>"),
1120            ("card-b.css", ".b { color: blue; }"),
1121        ]);
1122        let result = build(default_options(app.path())).unwrap();
1123
1124        assert_eq!(result.css_files.len(), 1);
1125        assert_eq!(result.css_files[0].0, "card-a.css");
1126    }
1127
1128    #[test]
1129    fn test_inspect_roundtrip_preserves_content() {
1130        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1131        let result = build(default_options(app.path())).unwrap();
1132
1133        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1134        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1135
1136        let fragments = &parsed["fragments"]["index.html"]["fragments"];
1137        assert!(fragments.is_array());
1138        assert!(!fragments.as_array().unwrap().is_empty());
1139    }
1140
1141    #[test]
1142    fn test_build_nonexistent_app_dir() {
1143        let options = BuildOptions {
1144            app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
1145            ..BuildOptions::default()
1146        };
1147        let result = build(options);
1148        assert!(result.is_err());
1149    }
1150
1151    #[test]
1152    fn test_build_to_disk_returns_accurate_stats() {
1153        let app = create_app_dir(&[
1154            ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
1155            ("my-card.html", "<div><slot></slot></div>"),
1156            ("my-card.css", ".card { color: red; }"),
1157        ]);
1158        let out = TempDir::new().unwrap();
1159
1160        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1161
1162        assert!(stats.fragment_count > 0);
1163        assert_eq!(stats.css_file_count, 1);
1164        assert!(stats.component_count > 0);
1165        assert!(stats.protocol_size_bytes > 0);
1166        assert!(!stats.duration.is_zero());
1167    }
1168}