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