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