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 component_assets;
24mod error;
25pub mod server;
26pub mod streaming;
27
28pub use component_assets::{render_component_assets, ComponentAssetFile};
29pub use error::WebUIError;
30
31// Re-export core types from downstream crates
32pub use webui_handler::route_handler::{
33    encode_inventory, get_needed_components, get_needed_components_for_request, parse_inventory,
34    ProtocolIndex,
35};
36pub use webui_handler::route_matcher::CompiledRouteCache;
37pub use webui_handler::Result as HandlerResult;
38pub use webui_handler::{
39    plugin::HandlerPlugin, HandlerError, RenderOptions, ResponseWriter, WebUIHandler,
40};
41pub use webui_parser::plugin::ComponentTemplateArtifact;
42pub use webui_parser::CssStrategy;
43pub use webui_parser::DomStrategy;
44pub use webui_parser::LegalComments;
45pub use webui_parser::ParserError;
46pub use webui_parser::ParserOptions;
47pub use webui_parser::Plugin;
48pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
49pub use webui_parser::{
50    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
51};
52pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
53pub use webui_parser::{Diagnostic, Severity};
54pub use webui_protocol::WebUIProtocol;
55pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
56
57use std::collections::HashSet;
58use std::ffi::{OsStr, OsString};
59use std::fs;
60use std::path::Path;
61use std::time::Instant;
62use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
63use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
64use webui_parser::plugin::webui::WebUIParserPlugin;
65use webui_parser::plugin::ParserPluginArtifacts;
66use webui_parser::HtmlParser;
67
68/// Options for building a WebUI application.
69#[derive(Debug, Clone)]
70pub struct BuildOptions {
71    /// Path to the application folder containing templates.
72    pub app_dir: std::path::PathBuf,
73    /// Entry HTML file name (e.g., `"index.html"`).
74    pub entry: String,
75    /// CSS delivery strategy for component stylesheets.
76    pub css: CssStrategy,
77    /// DOM strategy for component rendering (shadow or light).
78    pub dom: DomStrategy,
79    /// Framework plugin to load.
80    pub plugin: Option<Plugin>,
81    /// Additional component sources (npm packages or local paths).
82    pub components: Vec<String>,
83    /// Additional root components to compile for static component asset emission.
84    ///
85    /// These roots are parsed into the protocol so their templates, styles, and
86    /// dependency closures can be emitted later, but they are not connected to
87    /// the entry fragment and therefore are not rendered during initial SSR.
88    pub component_asset_roots: Vec<String>,
89    /// Emitted asset filename template using `[name]`, `[hash]`, and `[ext]`.
90    ///
91    /// Applies to Link-mode CSS files and static component assets.
92    pub css_file_name_template: String,
93    /// Optional URL/base-path prefix for Link-mode `css_href` values.
94    /// When set, emitted protocol hrefs become `<base>/<filename>`.
95    pub css_public_base: Option<String>,
96    /// Legal comment preservation strategy.
97    pub legal_comments: LegalComments,
98    /// Optional loaded design-token theme used to validate discovered CSS tokens.
99    ///
100    /// When present, every discovered CSS token that remains unresolved after
101    /// local and ancestor CSS definitions are removed must exist in every theme,
102    /// unless the `var()` usage supplies a literal CSS fallback (e.g.
103    /// `var(--x, 16px)`), which provides its own value. Missing required tokens
104    /// fail the build as parser diagnostics.
105    pub theme: Option<TokenFile>,
106}
107
108impl Default for BuildOptions {
109    fn default() -> Self {
110        Self {
111            app_dir: std::path::PathBuf::from("."),
112            entry: "index.html".to_string(),
113            css: CssStrategy::Link,
114            dom: DomStrategy::Shadow,
115            plugin: None,
116            components: Vec::new(),
117            component_asset_roots: Vec::new(),
118            css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
119            css_public_base: None,
120            legal_comments: LegalComments::default(),
121            theme: None,
122        }
123    }
124}
125
126/// Statistics about a completed build.
127#[derive(Debug, Clone)]
128pub struct BuildStats {
129    /// Total wall-clock build time.
130    pub duration: std::time::Duration,
131    /// Total number of protocol fragments.
132    pub fragment_count: usize,
133    /// Number of registered components.
134    pub component_count: usize,
135    /// Number of CSS files produced.
136    pub css_file_count: usize,
137    /// Size of the serialized protocol in bytes.
138    pub protocol_size_bytes: usize,
139    /// Number of unique CSS tokens discovered.
140    pub token_count: usize,
141}
142
143/// Result of a successful build.
144#[derive(Debug)]
145pub struct BuildResult {
146    /// The compiled WebUI protocol.
147    pub protocol: WebUIProtocol,
148    /// Serialized protocol bytes (protobuf binary).
149    pub protocol_bytes: Vec<u8>,
150    /// Component CSS files: `(filename, content)` — only components referenced in the protocol.
151    pub css_files: Vec<(String, String)>,
152    /// Static component asset files: `(filename, ESM module content)`.
153    ///
154    /// Populated when [`BuildOptions::component_asset_roots`] is non-empty.
155    pub component_asset_files: Vec<ComponentAssetFile>,
156    /// Component client template payloads.
157    /// Includes templates for all components encountered during parsing,
158    /// including route-referenced components.
159    pub component_templates: Vec<ComponentTemplateArtifact>,
160    /// Non-fatal build advisories as warning-severity [`Diagnostic`]s.
161    ///
162    /// Currently surfaces CSS tokens that are referenced only with a literal
163    /// `var()` fallback and defined in no theme — often typos. Empty when no
164    /// theme is supplied. Carries the same structured location/snippet/`help:`
165    /// data as errors; the entry point decides how to present (and color) them.
166    pub warnings: Vec<Diagnostic>,
167    /// Build statistics.
168    pub stats: BuildStats,
169}
170
171/// Build a WebUI application from an app directory.
172///
173/// Parses templates, discovers components, and produces a compiled protocol
174/// with build statistics.
175///
176/// # Errors
177///
178/// Returns [`WebUIError`] if the app directory is invalid, templates fail
179/// to parse, theme tokens are incomplete, or the protocol cannot be serialized.
180#[must_use = "BuildResult contains the compiled protocol and statistics"]
181pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
182    let started = Instant::now();
183
184    let raw = build_protocol_inner(&options)?;
185
186    let protocol_bytes = raw.protocol.to_protobuf()?;
187
188    let stats = BuildStats {
189        duration: started.elapsed(),
190        fragment_count: raw.fragment_count,
191        component_count: raw.component_count,
192        css_file_count: raw.css_files.len(),
193        protocol_size_bytes: protocol_bytes.len(),
194        token_count: raw.token_count,
195    };
196
197    Ok(BuildResult {
198        protocol: raw.protocol,
199        protocol_bytes,
200        css_files: raw.css_files,
201        component_asset_files: raw.component_asset_files,
202        component_templates: raw.component_templates,
203        warnings: raw.warnings,
204        stats,
205    })
206}
207
208/// Build a WebUI application and write output files to disk.
209///
210/// Writes `protocol.bin`, any external CSS files, and static component assets
211/// to `out_dir`.
212/// Creates `out_dir` if it does not exist.
213///
214/// # Errors
215///
216/// Returns [`WebUIError`] on build failure or if output files cannot be written.
217pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
218    let result = build(options)?;
219    validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
220
221    fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
222        context: format!("Failed to create {}", out_dir.display()),
223        source,
224    })?;
225
226    fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
227        WebUIError::Io {
228            context: format!("Failed to write protocol.bin to {}", out_dir.display()),
229            source,
230        }
231    })?;
232
233    for (name, content) in &result.css_files {
234        fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
235            context: format!("Failed to write {name} to {}", out_dir.display()),
236            source,
237        })?;
238    }
239    for file in &result.component_asset_files {
240        fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
241            context: format!(
242                "Failed to write component asset {} to {}",
243                file.name,
244                out_dir.display()
245            ),
246            source,
247        })?;
248    }
249
250    Ok(result.stats)
251}
252
253/// Inspect a compiled WebUI protocol file and return its JSON representation.
254pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
255    let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
256        context: format!("Failed to read {}", protocol_path.display()),
257        source,
258    })?;
259    inspect_bytes(&bytes)
260}
261
262/// Inspect raw protocol bytes and return their JSON representation.
263pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
264    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
265    protocol
266        .to_json_pretty()
267        .map_err(|e| WebUIError::Serialization(e.to_string()))
268}
269
270/// Internal intermediate build output before stats are computed.
271struct RawBuildOutput {
272    protocol: WebUIProtocol,
273    css_files: Vec<(String, String)>,
274    component_asset_files: Vec<ComponentAssetFile>,
275    component_templates: Vec<ComponentTemplateArtifact>,
276    warnings: Vec<Diagnostic>,
277    fragment_count: usize,
278    component_count: usize,
279    token_count: usize,
280}
281
282/// Internal build logic shared by `build()` and `build_to_disk()`.
283fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
284    let parser_options = ParserOptions::try_new(
285        options.css,
286        options.dom,
287        &options.css_file_name_template,
288        options.css_public_base.as_deref(),
289        options.legal_comments,
290    )
291    .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
292    let css_link_options = parser_options.css_link_options.clone();
293
294    let mut parser = match options.plugin {
295        Some(Plugin::Fast | Plugin::FastV2) => {
296            HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
297        }
298        Some(Plugin::FastV3) => {
299            HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
300        }
301        Some(Plugin::WebUI) => {
302            HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
303        }
304        None => HtmlParser::with_options(parser_options),
305    };
306
307    // Register app directory components
308    parser
309        .component_registry_mut()
310        .register_from_paths(&[&options.app_dir])
311        .map_err(|e| {
312            WebUIError::ComponentRegistration(format!(
313                "Failed to register components from {}: {e}",
314                options.app_dir.display()
315            ))
316        })?;
317
318    // Discover and register external component sources
319    for source in &options.components {
320        let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
321            WebUIError::ComponentDiscovery(format!(
322                "Failed to discover components from {source}: {e}"
323            ))
324        })?;
325        for comp in &result.components {
326            parser
327                .component_registry_mut()
328                .register_component(
329                    &comp.tag_name,
330                    &comp.html_content,
331                    comp.css_content.as_deref(),
332                    comp.has_script,
333                )
334                .map_err(|e| {
335                    WebUIError::ComponentRegistration(format!(
336                        "Failed to register component '{}' from {}: {e}",
337                        comp.tag_name, comp.source
338                    ))
339                })?;
340        }
341    }
342
343    let component_count = parser.component_registry().len();
344
345    // Parse entry HTML
346    let entry_path = options.app_dir.join(&options.entry);
347    let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
348        context: format!("Failed to read {}", entry_path.display()),
349        source,
350    })?;
351    parser
352        .parse(&options.entry, &html_content)
353        .map_err(|source| WebUIError::Parse {
354            context: format!("Failed to parse {}", options.entry),
355            source,
356        })?;
357
358    let synthetic_asset_fragments =
359        parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
360
361    let css_snapshot: Vec<(String, String)> = parser
362        .component_registry()
363        .get_all()
364        .filter(|component| parser.has_fragment(&component.tag_name))
365        .filter_map(|component| {
366            component
367                .css_content
368                .as_ref()
369                .map(|css| (component.tag_name.clone(), css.clone()))
370        })
371        .collect();
372
373    // Collect CSS token analysis before consuming the parser.
374    let token_analysis = parser.token_analysis();
375    let mut warnings: Vec<Diagnostic> = Vec::new();
376    if let Some(theme) = options.theme.as_ref() {
377        token_analysis
378            .validate_theme_tokens(theme)
379            .map_err(|source| WebUIError::Parse {
380                context: "Failed to validate theme tokens".to_string(),
381                source,
382            })?;
383        // Advisory: a token used only as `var(--x, <literal>)` and absent from
384        // every theme is often a misspelling. The literal keeps the build green,
385        // so this is a warning (with a "did you mean …?" suggestion), not an
386        // error.
387        warnings = token_analysis.theme_token_warnings(theme);
388    }
389    let token_count = token_analysis.protocol_tokens.len();
390
391    let component_templates =
392        match parser
393            .take_plugin_artifacts()
394            .map_err(|source| WebUIError::Parse {
395                context: format!(
396                    "Failed to compile component templates for {}",
397                    options.entry
398                ),
399                source,
400            })? {
401            ParserPluginArtifacts::None => Vec::new(),
402            ParserPluginArtifacts::ComponentTemplates(templates) => templates,
403        };
404    // Build protocol (consumes parser)
405    let mut fragment_records = parser.into_fragment_records();
406    for fragment_id in synthetic_asset_fragments {
407        fragment_records.remove(&fragment_id);
408    }
409    let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
410
411    let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
412
413    // Record build-wide strategies so the handler can decide rendering behavior.
414    protocol.set_css_strategy(match options.css {
415        CssStrategy::Link => webui_protocol::CssStrategy::Link,
416        CssStrategy::Style => webui_protocol::CssStrategy::Style,
417        CssStrategy::Module => webui_protocol::CssStrategy::Module,
418    });
419    protocol.set_dom_strategy(match options.dom {
420        DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
421        DomStrategy::Light => webui_protocol::DomStrategy::Light,
422    });
423
424    // Process component CSS in a single pass: store Module CSS content,
425    // set Link-strategy css_href, and collect external CSS files.
426    let is_module = options.css == CssStrategy::Module;
427    let is_link = options.css == CssStrategy::Link;
428    let mut css_files: Vec<(String, String)> = Vec::new();
429    let mut emitted_names: HashSet<String> = HashSet::new();
430    for (tag, css) in css_snapshot {
431        if !protocol.fragments.contains_key(&tag) {
432            continue;
433        }
434        if is_module {
435            protocol.components.entry(tag).or_default().css = css.trim().to_string();
436        } else if is_link {
437            let resolved = css_link_options.resolve(&tag, &css);
438            if !emitted_names.insert(resolved.filename.clone()) {
439                return Err(WebUIError::InvalidBuildOptions(format!(
440                    "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
441                    resolved.filename
442                )));
443            }
444            protocol.components.entry(tag).or_default().css_href = resolved.href;
445            css_files.push((resolved.filename, css));
446        }
447        // Style strategy: CSS is already baked into raw fragments by the
448        // parser — nothing to store in the protocol or emit as files.
449    }
450
451    // Store client templates in the protocol so any host server can query them.
452    for artifact in &component_templates {
453        let component = protocol
454            .components
455            .entry(artifact.tag_name.clone())
456            .or_default();
457        component.template = artifact.template.clone();
458        component.template_json = artifact.template_json.clone();
459        component.template_functions = artifact.template_functions.clone();
460    }
461
462    let component_asset_files = component_assets::render_component_assets(
463        &protocol,
464        &options.component_asset_roots,
465        &options.css_file_name_template,
466    )?;
467    validate_generated_file_names(&css_files, &component_asset_files)?;
468
469    Ok(RawBuildOutput {
470        protocol,
471        css_files,
472        component_asset_files,
473        component_templates,
474        warnings,
475        fragment_count,
476        component_count,
477        token_count,
478    })
479}
480
481fn parse_component_asset_roots(
482    parser: &mut HtmlParser,
483    roots: &[String],
484) -> Result<Vec<String>, WebUIError> {
485    let mut synthetic_fragments = Vec::with_capacity(roots.len());
486    for (index, root) in roots.iter().enumerate() {
487        let root = root.trim();
488        if parser.has_fragment(root) {
489            continue;
490        }
491        // Compile the requested lazy root without connecting it to the entry
492        // graph, so it can be emitted as a static asset but stays out of SSR.
493        let mut synthetic = String::with_capacity(root.len() * 2 + 5);
494        synthetic.push('<');
495        synthetic.push_str(root);
496        synthetic.push_str("></");
497        synthetic.push_str(root);
498        synthetic.push('>');
499
500        let fragment_id = format!("__webui_asset_root_{index}");
501        parser
502            .parse(&fragment_id, &synthetic)
503            .map_err(|source| WebUIError::Parse {
504                context: format!("Failed to parse component asset root <{root}>"),
505                source,
506            })?;
507        synthetic_fragments.push(fragment_id);
508    }
509    Ok(synthetic_fragments)
510}
511
512fn validate_generated_file_names(
513    css_files: &[(String, String)],
514    component_asset_files: &[ComponentAssetFile],
515) -> Result<(), WebUIError> {
516    let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
517    for (name, _) in css_files {
518        names.insert(name.as_str());
519    }
520    for file in component_asset_files {
521        if !names.insert(file.name.as_str()) {
522            return Err(WebUIError::InvalidBuildOptions(format!(
523                "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
524                file.name
525            )));
526        }
527    }
528    Ok(())
529}
530
531fn validate_output_file_names(
532    protocol_name: &OsStr,
533    result: &BuildResult,
534) -> Result<(), WebUIError> {
535    let mut names =
536        HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
537    names.insert(protocol_name.to_os_string());
538    for (name, _) in &result.css_files {
539        let file_name = OsString::from(name);
540        if !names.insert(file_name.clone()) {
541            return Err(output_file_collision_error(&file_name));
542        }
543    }
544    for file in &result.component_asset_files {
545        let file_name = OsString::from(&file.name);
546        if !names.insert(file_name.clone()) {
547            return Err(output_file_collision_error(&file_name));
548        }
549    }
550    Ok(())
551}
552
553fn output_file_collision_error(name: &OsStr) -> WebUIError {
554    WebUIError::InvalidBuildOptions(format!(
555        "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
556        name.to_string_lossy()
557    ))
558}
559
560#[cfg(test)]
561#[allow(clippy::disallowed_methods)]
562mod tests {
563    use super::*;
564    use std::collections::HashMap;
565    use std::path::PathBuf;
566    use tempfile::TempDir;
567    use webui_protocol::web_ui_fragment::Fragment;
568
569    struct StringWriter {
570        buf: String,
571    }
572
573    impl ResponseWriter for StringWriter {
574        fn write(&mut self, content: &str) -> webui_handler::Result<()> {
575            self.buf.push_str(content);
576            Ok(())
577        }
578
579        fn end(&mut self) -> webui_handler::Result<()> {
580            Ok(())
581        }
582    }
583
584    fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
585        let dir = TempDir::new().unwrap();
586        for (name, content) in files {
587            let path = dir.path().join(name);
588            if let Some(parent) = path.parent() {
589                fs::create_dir_all(parent).unwrap();
590            }
591            fs::write(&path, content).unwrap();
592        }
593        dir
594    }
595
596    fn default_options(app_dir: &Path) -> BuildOptions {
597        BuildOptions {
598            app_dir: app_dir.to_path_buf(),
599            ..BuildOptions::default()
600        }
601    }
602
603    #[test]
604    fn test_build_simple_html() {
605        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
606        let result = build(default_options(app.path())).unwrap();
607
608        assert!(result.protocol.fragments.contains_key("index.html"));
609        assert!(result.stats.fragment_count > 0);
610        assert!(result.stats.protocol_size_bytes > 0);
611        assert!(!result.stats.duration.is_zero());
612    }
613
614    #[test]
615    fn test_build_marks_stateful_html_only_component_as_template_host() {
616        let app = create_app_dir(&[
617            ("index.html", "<demo-card></demo-card>"),
618            ("demo-card.html", "<p>{{name}}</p>"),
619        ]);
620        let mut options = default_options(app.path());
621        options.plugin = Some(Plugin::WebUI);
622        let result = build(options).unwrap();
623
624        assert!(result
625            .component_templates
626            .iter()
627            .any(|template| template.tag_name == "demo-card"
628                && template.template_json.contains(r#""th":1"#)));
629        assert!(result.protocol.components.contains_key("demo-card"));
630    }
631
632    #[test]
633    fn test_build_treats_sibling_component_script_as_scripted() {
634        let app = create_app_dir(&[
635            ("index.html", "<demo-card></demo-card>"),
636            (
637                "demo-card.html",
638                r#"<button @click="{onClick()}">{{name}}</button>"#,
639            ),
640            (
641                "demo-card.ts",
642                "import { WebUIElement } from '@microsoft/webui-framework';\n\
643                 class DemoCard extends WebUIElement { onClick() {} }\n\
644                 DemoCard.define('demo-card');",
645            ),
646        ]);
647        let mut options = default_options(app.path());
648        options.plugin = Some(Plugin::WebUI);
649        let result = build(options).unwrap();
650
651        let template = result
652            .component_templates
653            .iter()
654            .find(|template| template.tag_name == "demo-card")
655            .unwrap();
656        assert!(!template.template_json.contains(r#""th":1"#));
657        assert!(template.template_json.contains(r#""eg":[["click""#));
658    }
659
660    #[test]
661    fn test_build_rejects_scriptless_event_component() {
662        let app = create_app_dir(&[
663            ("index.html", "<demo-card></demo-card>"),
664            (
665                "demo-card.html",
666                r#"<button @click="{onClick()}">Click</button>"#,
667            ),
668        ]);
669        let mut options = default_options(app.path());
670        options.plugin = Some(Plugin::WebUI);
671        let err = build(options).expect_err("scriptless events should fail");
672
673        let message = err.chain_message();
674        assert!(
675            message.contains("scriptless-event-handler"),
676            "msg: {message}"
677        );
678    }
679
680    #[test]
681    fn test_build_returns_actionable_error_for_invalid_w_ref() {
682        let app = create_app_dir(&[
683            ("index.html", "<my-card>Hello</my-card>"),
684            ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
685        ]);
686        let mut options = default_options(app.path());
687        options.plugin = Some(Plugin::WebUI);
688
689        // Authoring mistakes are returned (not panicked) so every consumer —
690        // CLI, Node, FFI, WASM — can surface the message its own way.
691        let Err(err) = build(options) else {
692            panic!("non-braced w-ref must fail the build");
693        };
694        let msg = err.chain_message();
695        assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
696        assert!(
697            msg.contains("component <my-card> · element <input>"),
698            "msg: {msg}"
699        );
700        assert!(msg.contains("help:"), "msg: {msg}");
701    }
702
703    #[test]
704    fn test_build_returns_actionable_error_for_invalid_event_handler() {
705        let app = create_app_dir(&[
706            ("index.html", "<my-btn>x</my-btn>"),
707            (
708                "my-btn.html",
709                r#"<div><button @click="e.preventDefault()">x</button></div>"#,
710            ),
711        ]);
712        let mut options = default_options(app.path());
713        options.plugin = Some(Plugin::WebUI);
714
715        let Err(err) = build(options) else {
716            panic!("invalid @event handler must fail the build");
717        };
718        let msg = err.chain_message();
719        assert!(msg.contains("invalid @click handler"), "msg: {msg}");
720        assert!(
721            msg.contains("component <my-btn> · element <button>"),
722            "msg: {msg}"
723        );
724    }
725
726    #[test]
727    fn test_error_chain_message_has_no_duplicate_source() {
728        let err = WebUIError::Parse {
729            context: "Failed to parse index.html".to_string(),
730            source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
731        };
732        // Each layer's Display describes only its own level — the source is not
733        // embedded — so anyhow's `{:#}` / chain walks never repeat it.
734        assert_eq!(err.to_string(), "Failed to parse index.html");
735        // The flat message includes the source exactly once (no repetition).
736        assert_eq!(
737            err.chain_message(),
738            "Failed to parse index.html: Directive error: Invalid for each: x"
739        );
740    }
741
742    #[test]
743    fn test_build_with_directives() {
744        let html = r#"<h1>Hello</h1>
745<for each="item in items">
746    <p>{{item.name}}</p>
747</for>
748<if condition="show">
749    <p>Visible</p>
750</if>"#;
751        let app = create_app_dir(&[("index.html", html)]);
752        let result = build(default_options(app.path())).unwrap();
753
754        let index = &result.protocol.fragments["index.html"].fragments;
755        assert!(index
756            .iter()
757            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
758        assert!(index
759            .iter()
760            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
761    }
762
763    #[test]
764    fn test_build_with_component_css() {
765        let app = create_app_dir(&[
766            ("index.html", "<my-card>Hello</my-card>"),
767            ("my-card.html", "<div><slot></slot></div>"),
768            ("my-card.css", ".card { color: red; }"),
769        ]);
770        let result = build(default_options(app.path())).unwrap();
771
772        assert_eq!(result.css_files.len(), 1);
773        assert_eq!(result.css_files[0].0, "my-card.css");
774        assert!(result.css_files[0].1.contains("color: red"));
775        assert_eq!(result.stats.css_file_count, 1);
776    }
777
778    #[test]
779    fn test_build_strips_non_legal_css_comments_from_output_file() {
780        let app = create_app_dir(&[
781            ("index.html", "<my-card>Hello</my-card>"),
782            ("my-card.html", "<div><slot></slot></div>"),
783            (
784                "my-card.css",
785                "/* remove var(--ignored) */ .card { color: var(--textColor); }",
786            ),
787        ]);
788        let result = build(default_options(app.path())).unwrap();
789
790        assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
791        assert_eq!(result.protocol.tokens, vec!["textColor"]);
792    }
793
794    #[test]
795    fn test_build_preserves_legal_css_comments_by_default() {
796        let app = create_app_dir(&[
797            ("index.html", "<my-card>Hello</my-card>"),
798            ("my-card.html", "<div><slot></slot></div>"),
799            (
800                "my-card.css",
801                "/*! @license MIT */ .card { color: red; } /* remove */",
802            ),
803        ]);
804        let result = build(default_options(app.path())).unwrap();
805
806        assert_eq!(
807            result.css_files[0].1,
808            "/*! @license MIT */ .card { color: red; } "
809        );
810    }
811
812    #[test]
813    fn test_build_legal_comments_none_strips_legal_css_comments() {
814        let app = create_app_dir(&[
815            ("index.html", "<my-card>Hello</my-card>"),
816            ("my-card.html", "<div><slot></slot></div>"),
817            ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
818        ]);
819        let mut options = default_options(app.path());
820        options.legal_comments = LegalComments::None;
821        let result = build(options).unwrap();
822
823        assert_eq!(result.css_files[0].1, " .card { color: red; }");
824    }
825
826    #[test]
827    fn test_build_with_css_hashed_filename_template() {
828        let app = create_app_dir(&[
829            ("index.html", "<my-card>Hello</my-card>"),
830            ("my-card.html", "<div><slot></slot></div>"),
831            ("my-card.css", ".card { color: red; }"),
832        ]);
833        let mut options = default_options(app.path());
834        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
835        let result = build(options).unwrap();
836
837        assert_eq!(result.css_files.len(), 1);
838        let generated = &result.css_files[0].0;
839        assert!(generated.starts_with("my-card-"));
840        assert!(generated.ends_with(".css"));
841        assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
842        assert_eq!(
843            result
844                .protocol
845                .components
846                .get("my-card")
847                .map(|c| c.css_href.as_str()),
848            Some(generated.as_str())
849        );
850    }
851
852    #[test]
853    fn test_css_public_base_prefixes_css_href_only() {
854        let app = create_app_dir(&[
855            ("index.html", "<my-card>Hello</my-card>"),
856            ("my-card.html", "<div><slot></slot></div>"),
857            ("my-card.css", ".card { color: red; }"),
858        ]);
859        let mut options = default_options(app.path());
860        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
861        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
862        let result = build(options).unwrap();
863
864        let filename = &result.css_files[0].0;
865        assert!(filename.starts_with("my-card-"));
866        let expected_href = format!("https://cdn.example.com/assets/{filename}");
867        assert_eq!(
868            result
869                .protocol
870                .components
871                .get("my-card")
872                .map(|c| c.css_href.as_str()),
873            Some(expected_href.as_str())
874        );
875    }
876
877    #[test]
878    fn test_css_public_base_emits_parser_and_handler_links() {
879        let app = create_app_dir(&[
880            (
881                "index.html",
882                "<html><head></head><body><my-card>Hello</my-card></body></html>",
883            ),
884            ("my-card.html", "<div><slot></slot></div>"),
885            ("my-card.css", ".card { color: red; }"),
886        ]);
887        let mut options = default_options(app.path());
888        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
889        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
890        let result = build(options).unwrap();
891
892        let filename = &result.css_files[0].0;
893        let expected_href = format!("https://cdn.example.com/assets/{filename}");
894        let component_html = result.protocol.fragments["my-card"]
895            .fragments
896            .iter()
897            .filter_map(|fragment| match fragment.fragment.as_ref() {
898                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
899                _ => None,
900            })
901            .collect::<String>();
902        assert!(
903            component_html.contains(&format!(
904                r#"<link rel="stylesheet" href="{expected_href}">"#
905            )),
906            "parser-generated component template should use CDN href: {component_html}"
907        );
908
909        let handler = WebUIHandler::new();
910        let mut writer = StringWriter { buf: String::new() };
911        handler
912            .handle(
913                &result.protocol,
914                &serde_json::json!({}),
915                &RenderOptions::new("index.html", "/"),
916                &mut writer,
917            )
918            .unwrap();
919        assert!(
920            writer.buf.contains(&format!(
921                r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
922            )),
923            "handler head preload should use CDN href: {}",
924            writer.buf
925        );
926    }
927
928    #[test]
929    fn test_css_public_base_emits_webui_plugin_template_links() {
930        let app = create_app_dir(&[
931            ("index.html", "<my-card>Hello</my-card>"),
932            ("my-card.html", "<div><slot></slot></div>"),
933            ("my-card.css", ".card { color: red; }"),
934        ]);
935        let mut options = default_options(app.path());
936        options.plugin = Some(Plugin::WebUI);
937        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
938        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
939        let result = build(options).unwrap();
940
941        let filename = &result.css_files[0].0;
942        let expected_href = format!("https://cdn.example.com/assets/{filename}");
943        let template = &result.protocol.components["my-card"].template_json;
944        assert!(
945            template.contains(&format!(r#"href=\"{expected_href}\""#)),
946            "plugin component template should use CDN href: {template}"
947        );
948    }
949
950    #[test]
951    fn test_css_public_base_emits_fast_plugin_template_links() {
952        let app = create_app_dir(&[
953            ("index.html", "<my-card>Hello</my-card>"),
954            ("my-card.html", "<div><slot></slot></div>"),
955            ("my-card.css", ".card { color: red; }"),
956        ]);
957        let mut options = default_options(app.path());
958        options.plugin = Some(Plugin::FastV3);
959        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
960        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
961        let result = build(options).unwrap();
962
963        let filename = &result.css_files[0].0;
964        let expected_href = format!("https://cdn.example.com/assets/{filename}");
965        let template = &result.protocol.components["my-card"].template;
966        assert!(
967            template.contains(&format!(r#"href="{expected_href}""#)),
968            "FAST component template should use CDN href: {template}"
969        );
970    }
971
972    #[test]
973    fn test_invalid_css_template_is_rejected() {
974        let app = create_app_dir(&[
975            ("index.html", "<my-card>Hello</my-card>"),
976            ("my-card.html", "<div><slot></slot></div>"),
977            ("my-card.css", ".card { color: red; }"),
978        ]);
979        let mut options = default_options(app.path());
980        options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
981        let result = build(options);
982
983        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
984    }
985
986    #[test]
987    fn test_css_filename_collision_is_rejected() {
988        let app = create_app_dir(&[
989            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
990            ("card-a.html", "<div><slot></slot></div>"),
991            ("card-a.css", ".x { color: red; }"),
992            ("card-b.html", "<div><slot></slot></div>"),
993            ("card-b.css", ".x { color: red; }"),
994        ]);
995        let mut options = default_options(app.path());
996        options.css_file_name_template = "[hash].[ext]".to_string();
997        let result = build(options);
998
999        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1000    }
1001
1002    #[test]
1003    fn test_build_returns_static_component_asset_files() {
1004        let app = create_app_dir(&[
1005            ("index.html", "<app-shell></app-shell>"),
1006            ("app-shell.html", "<div></div>"),
1007            (
1008                "lazy-panel.html",
1009                r#"<if condition="ready"><p>{{title}}</p></if>"#,
1010            ),
1011        ]);
1012        let mut options = default_options(app.path());
1013        options.plugin = Some(Plugin::WebUI);
1014        options.component_asset_roots = vec!["lazy-panel".to_string()];
1015
1016        let result = build(options).unwrap();
1017
1018        assert_eq!(result.component_asset_files.len(), 1);
1019        assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
1020        assert!(result.component_asset_files[0]
1021            .content
1022            .contains(r#""type":"webui-component-asset""#));
1023        assert!(result.component_asset_files[0]
1024            .content
1025            .contains(r#""templateFunctions":{"lazy-panel":"#));
1026        assert!(result.protocol.fragments.contains_key("lazy-panel"));
1027        assert!(
1028            !result
1029                .protocol
1030                .fragments
1031                .keys()
1032                .any(|key| key.starts_with("__webui_asset_root_")),
1033            "synthetic asset root fragments must not be serialized"
1034        );
1035    }
1036
1037    #[test]
1038    fn test_component_asset_filename_collision_with_css_is_rejected() {
1039        let app = create_app_dir(&[
1040            ("index.html", "<app-shell></app-shell>"),
1041            ("app-shell.html", "<div></div>"),
1042            ("lazy-panel.html", "<p>Lazy</p>"),
1043            ("lazy-panel.css", ".panel { color: red; }"),
1044        ]);
1045        let mut options = default_options(app.path());
1046        options.plugin = Some(Plugin::WebUI);
1047        options.component_asset_roots = vec!["lazy-panel".to_string()];
1048        options.css_file_name_template = "[name]".to_string();
1049
1050        let result = build(options);
1051
1052        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1053    }
1054
1055    #[test]
1056    fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
1057        let app = create_app_dir(&[
1058            ("index.html", "<app-shell></app-shell>"),
1059            ("app-shell.html", "<div></div>"),
1060            ("lazy-panel.html", "<p>Lazy</p>"),
1061        ]);
1062        let out = TempDir::new().unwrap();
1063        let mut options = default_options(app.path());
1064        options.plugin = Some(Plugin::WebUI);
1065        options.component_asset_roots = vec!["lazy-panel".to_string()];
1066        options.css_file_name_template = "protocol.bin".to_string();
1067
1068        let result = build_to_disk(options, out.path());
1069
1070        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1071        assert!(!out.path().join("protocol.bin").exists());
1072    }
1073
1074    #[test]
1075    fn test_css_href_set_for_light_dom_link_strategy() {
1076        let app = create_app_dir(&[
1077            ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
1078            ("has-css.html", "<p><slot></slot></p>"),
1079            ("has-css.css", ".yes { color: green; }"),
1080            ("no-css.html", "<p><slot></slot></p>"),
1081        ]);
1082        let mut options = default_options(app.path());
1083        options.dom = DomStrategy::Light;
1084        let result = build(options).unwrap();
1085
1086        let href = result
1087            .protocol
1088            .components
1089            .get("has-css")
1090            .map(|c| c.css_href.as_str())
1091            .unwrap_or("");
1092        assert_eq!(
1093            href, "has-css.css",
1094            "Light×Link component with CSS should have css_href"
1095        );
1096
1097        let no_href = result
1098            .protocol
1099            .components
1100            .get("no-css")
1101            .map(|c| c.css_href.as_str())
1102            .unwrap_or("");
1103        assert!(
1104            no_href.is_empty(),
1105            "Component without CSS should have empty css_href"
1106        );
1107    }
1108
1109    #[test]
1110    fn test_shadow_dom_link_strategy_sets_css_href() {
1111        // Shadow×Link: css_href is always set for Link-strategy components.
1112        // The handler uses protocol.css_strategy + dom_strategy to decide
1113        // whether to emit preload (Shadow) or stylesheet (Light) in <head>.
1114        let app = create_app_dir(&[
1115            ("index.html", "<my-card>A</my-card>"),
1116            ("my-card.html", "<p><slot></slot></p>"),
1117            ("my-card.css", ".card { color: red; }"),
1118        ]);
1119        let result = build(default_options(app.path())).unwrap();
1120
1121        let comp = result.protocol.components.get("my-card").unwrap();
1122        assert_eq!(
1123            comp.css_href, "my-card.css",
1124            "Shadow×Link should set css_href"
1125        );
1126
1127        // Strategy fields on protocol
1128        assert_eq!(
1129            result.protocol.css_strategy(),
1130            webui_protocol::CssStrategy::Link,
1131        );
1132        assert_eq!(
1133            result.protocol.dom_strategy(),
1134            webui_protocol::DomStrategy::Shadow,
1135        );
1136
1137        // CSS file should still be emitted for the server to serve
1138        assert_eq!(
1139            result.css_files.len(),
1140            1,
1141            "CSS file should still be produced"
1142        );
1143    }
1144
1145    #[test]
1146    fn test_css_href_empty_for_style_strategy() {
1147        let app = create_app_dir(&[
1148            ("index.html", "<my-card>A</my-card>"),
1149            ("my-card.html", "<p><slot></slot></p>"),
1150            ("my-card.css", ".card { color: red; }"),
1151        ]);
1152        let mut options = default_options(app.path());
1153        options.css = CssStrategy::Style;
1154        let result = build(options).unwrap();
1155
1156        let href = result
1157            .protocol
1158            .components
1159            .get("my-card")
1160            .map(|c| c.css_href.as_str())
1161            .unwrap_or("");
1162        assert!(href.is_empty(), "Style-strategy should not set css_href");
1163    }
1164
1165    #[test]
1166    fn test_css_href_empty_for_module_strategy() {
1167        let app = create_app_dir(&[
1168            ("index.html", "<my-card>A</my-card>"),
1169            ("my-card.html", "<p><slot></slot></p>"),
1170            ("my-card.css", ".card { color: red; }"),
1171        ]);
1172        let mut options = default_options(app.path());
1173        options.css = CssStrategy::Module;
1174        let result = build(options).unwrap();
1175
1176        let href = result
1177            .protocol
1178            .components
1179            .get("my-card")
1180            .map(|c| c.css_href.as_str())
1181            .unwrap_or("");
1182        assert!(href.is_empty(), "Module-strategy should not set css_href");
1183        assert!(
1184            !result.protocol.components["my-card"].css.is_empty(),
1185            "Module-strategy should still populate css content"
1186        );
1187    }
1188
1189    #[test]
1190    fn test_build_to_disk_writes_files() {
1191        let app = create_app_dir(&[
1192            ("index.html", "<my-card>Hello</my-card>"),
1193            ("my-card.html", "<div><slot></slot></div>"),
1194            ("my-card.css", ".card { color: red; }"),
1195        ]);
1196        let out = TempDir::new().unwrap();
1197
1198        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1199
1200        assert!(out.path().join("protocol.bin").exists());
1201        assert!(out.path().join("my-card.css").exists());
1202        assert_eq!(stats.css_file_count, 1);
1203        assert!(stats.fragment_count > 0);
1204    }
1205
1206    #[test]
1207    fn test_build_to_disk_creates_nested_output_dir() {
1208        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1209        let out = TempDir::new().unwrap();
1210        let nested = out.path().join("nested").join("output");
1211
1212        let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
1213        assert!(nested.join("protocol.bin").exists());
1214        assert!(stats.fragment_count > 0);
1215    }
1216
1217    #[test]
1218    fn test_build_missing_entry() {
1219        let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
1220        let result = build(default_options(app.path()));
1221
1222        assert!(result.is_err());
1223        let err = result.unwrap_err();
1224        assert!(matches!(err, WebUIError::Io { .. }));
1225    }
1226
1227    #[test]
1228    fn test_build_stats_populated() {
1229        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1230        let result = build(default_options(app.path())).unwrap();
1231
1232        assert!(result.stats.fragment_count > 0);
1233        assert!(result.stats.protocol_size_bytes > 0);
1234        assert_eq!(result.stats.css_file_count, 0);
1235    }
1236
1237    #[test]
1238    fn test_build_inline_css() {
1239        let app = create_app_dir(&[
1240            ("index.html", "<my-card>Hello</my-card>"),
1241            ("my-card.html", "<div><slot></slot></div>"),
1242            ("my-card.css", ".card { color: red; }"),
1243        ]);
1244        let mut options = default_options(app.path());
1245        options.css = CssStrategy::Style;
1246
1247        let result = build(options).unwrap();
1248        // Inline mode embeds CSS in <style> tags — no external CSS files
1249        assert!(result.css_files.is_empty());
1250        assert_eq!(result.stats.css_file_count, 0);
1251        assert!(result.stats.fragment_count > 0);
1252    }
1253
1254    #[test]
1255    fn test_build_module_css() {
1256        let app = create_app_dir(&[
1257            ("index.html", "<my-card>Hello</my-card>"),
1258            ("my-card.html", "<div><slot></slot></div>"),
1259            ("my-card.css", ".card { color: red; }"),
1260        ]);
1261        let mut options = default_options(app.path());
1262        options.css = CssStrategy::Module;
1263
1264        let result = build(options).unwrap();
1265        // Module mode emits <script type="importmap"> data URIs — no external CSS files
1266        assert!(result.css_files.is_empty());
1267        assert_eq!(result.stats.css_file_count, 0);
1268        assert!(result.stats.fragment_count > 0);
1269    }
1270
1271    #[test]
1272    fn test_build_custom_entry() {
1273        let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
1274        let mut options = default_options(app.path());
1275        options.entry = "page.html".to_string();
1276
1277        let result = build(options).unwrap();
1278        assert!(result.protocol.fragments.contains_key("page.html"));
1279        assert!(!result.protocol.fragments.contains_key("index.html"));
1280    }
1281
1282    #[test]
1283    fn test_build_protocol_roundtrip() {
1284        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1285        let result = build(default_options(app.path())).unwrap();
1286
1287        let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1288        assert!(restored.fragments.contains_key("index.html"));
1289    }
1290
1291    #[test]
1292    fn test_inspect_bytes_valid() {
1293        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1294        let result = build(default_options(app.path())).unwrap();
1295
1296        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1297        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1298        assert!(parsed.get("fragments").is_some());
1299    }
1300
1301    #[test]
1302    fn test_inspect_bytes_invalid() {
1303        let result = inspect_bytes(b"not a protobuf");
1304        assert!(result.is_err());
1305        assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
1306    }
1307
1308    #[test]
1309    fn test_inspect_file() {
1310        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1311        let out = TempDir::new().unwrap();
1312        build_to_disk(default_options(app.path()), out.path()).unwrap();
1313
1314        let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
1315        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1316        assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
1317    }
1318
1319    #[test]
1320    fn test_inspect_missing_file() {
1321        let result = inspect(Path::new("/nonexistent/protocol.bin"));
1322        assert!(result.is_err());
1323        assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
1324    }
1325
1326    #[test]
1327    fn test_build_with_components_local_path() {
1328        let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
1329        let ext_dir = TempDir::new().unwrap();
1330        fs::write(
1331            ext_dir.path().join("ext-card.html"),
1332            "<div class=\"card\"><slot></slot></div>",
1333        )
1334        .unwrap();
1335        fs::write(
1336            ext_dir.path().join("ext-card.css"),
1337            ".card { border: 1px solid #ccc; }",
1338        )
1339        .unwrap();
1340
1341        let mut options = default_options(app.path());
1342        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1343
1344        let result = build(options).unwrap();
1345        assert!(result.protocol.fragments.contains_key("index.html"));
1346        assert_eq!(result.css_files.len(), 1);
1347        assert!(result.css_files[0].1.contains("border"));
1348    }
1349
1350    #[test]
1351    fn test_build_with_scriptless_component_path_emits_static_host() {
1352        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1353        let ext_dir = TempDir::new().unwrap();
1354        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1355
1356        let mut options = default_options(app.path());
1357        options.plugin = Some(Plugin::WebUI);
1358        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1359
1360        let result = build(options).unwrap();
1361        let template = &result.protocol.components["ext-card"].template_json;
1362        assert!(template.contains(r#""th":1"#), "template: {template}");
1363    }
1364
1365    #[test]
1366    fn test_build_with_scripted_component_path_skips_static_host() {
1367        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1368        let ext_dir = TempDir::new().unwrap();
1369        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1370        fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
1371
1372        let mut options = default_options(app.path());
1373        options.plugin = Some(Plugin::WebUI);
1374        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1375
1376        let result = build(options).unwrap();
1377        let template = &result.protocol.components["ext-card"].template_json;
1378        assert!(!template.contains(r#""th":1"#), "template: {template}");
1379    }
1380
1381    #[test]
1382    fn test_build_hello_world_example() {
1383        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1384        let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
1385
1386        let result = build(BuildOptions {
1387            app_dir,
1388            entry: "index.html".to_string(),
1389            ..BuildOptions::default()
1390        })
1391        .unwrap();
1392
1393        let index = &result.protocol.fragments["index.html"].fragments;
1394        assert!(index.iter().any(
1395            |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
1396        ));
1397        assert!(index
1398            .iter()
1399            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1400    }
1401
1402    // ── Security tests ───────────────────────────────────────────────
1403
1404    #[test]
1405    fn test_css_filename_sanitizes_path_separators() {
1406        // Even if a component tag somehow contains path separators,
1407        // the filename should be sanitized to prevent directory traversal.
1408        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1409        let result = build(default_options(app.path())).unwrap();
1410
1411        // Verify all CSS filenames are plain file names (no path separators)
1412        for (filename, _) in &result.css_files {
1413            assert!(
1414                !filename.contains('/') && !filename.contains('\\'),
1415                "CSS filename contains path separator: {filename}"
1416            );
1417        }
1418    }
1419
1420    #[test]
1421    fn test_build_to_disk_css_stays_in_output_dir() {
1422        let app = create_app_dir(&[
1423            ("index.html", "<my-card>Hello</my-card>"),
1424            ("my-card.html", "<div><slot></slot></div>"),
1425            ("my-card.css", ".card { color: red; }"),
1426        ]);
1427        let out = TempDir::new().unwrap();
1428
1429        build_to_disk(default_options(app.path()), out.path()).unwrap();
1430
1431        // Verify CSS file is written inside out_dir
1432        let css_path = out.path().join("my-card.css");
1433        assert!(css_path.exists());
1434        let canonical = css_path.canonicalize().unwrap();
1435        let out_canonical = out.path().canonicalize().unwrap();
1436        assert!(
1437            canonical.starts_with(&out_canonical),
1438            "CSS file escaped output directory"
1439        );
1440    }
1441
1442    // ── Performance / edge case tests ────────────────────────────────
1443
1444    #[test]
1445    fn test_build_empty_html() {
1446        let app = create_app_dir(&[("index.html", "")]);
1447        let result = build(default_options(app.path())).unwrap();
1448
1449        assert!(result.protocol.fragments.contains_key("index.html"));
1450        assert_eq!(result.stats.fragment_count, 0);
1451        assert!(result.stats.protocol_size_bytes > 0);
1452    }
1453
1454    #[test]
1455    fn test_build_large_fragment_count() {
1456        // Verify stats are accurate with many fragments
1457        let mut html = String::with_capacity(2000);
1458        for i in 0..50 {
1459            html.push_str(&format!("<p>Item {i}</p>\n"));
1460        }
1461        let app = create_app_dir(&[("index.html", &html)]);
1462        let result = build(default_options(app.path())).unwrap();
1463
1464        assert!(result.stats.fragment_count > 0);
1465        assert_eq!(result.stats.css_file_count, 0);
1466    }
1467
1468    #[test]
1469    fn test_build_with_fast_plugin() {
1470        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1471        let mut options = default_options(app.path());
1472        options.plugin = Some(Plugin::FastV3);
1473
1474        let result = build(options).unwrap();
1475        assert!(result.protocol.fragments.contains_key("index.html"));
1476    }
1477
1478    #[test]
1479    fn test_build_to_disk_inline_mode_no_css_files() {
1480        let app = create_app_dir(&[
1481            ("index.html", "<my-card>Hello</my-card>"),
1482            ("my-card.html", "<div><slot></slot></div>"),
1483            ("my-card.css", ".card { color: red; }"),
1484        ]);
1485        let out = TempDir::new().unwrap();
1486        let mut options = default_options(app.path());
1487        options.css = CssStrategy::Style;
1488
1489        let stats = build_to_disk(options, out.path()).unwrap();
1490
1491        assert!(out.path().join("protocol.bin").exists());
1492        assert!(!out.path().join("my-card.css").exists());
1493        assert_eq!(stats.css_file_count, 0);
1494    }
1495
1496    #[test]
1497    fn test_build_stats_duration_is_nonzero() {
1498        let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
1499        let result = build(default_options(app.path())).unwrap();
1500
1501        assert!(!result.stats.duration.is_zero());
1502    }
1503
1504    #[test]
1505    fn test_build_multiple_components_css() {
1506        let app = create_app_dir(&[
1507            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1508            ("card-a.html", "<div><slot></slot></div>"),
1509            ("card-a.css", ".a { color: red; }"),
1510            ("card-b.html", "<span><slot></slot></span>"),
1511            ("card-b.css", ".b { color: blue; }"),
1512        ]);
1513        let result = build(default_options(app.path())).unwrap();
1514
1515        assert_eq!(result.css_files.len(), 2);
1516        assert_eq!(result.stats.css_file_count, 2);
1517        let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
1518        assert!(filenames.contains(&"card-a.css"));
1519        assert!(filenames.contains(&"card-b.css"));
1520    }
1521
1522    #[test]
1523    fn test_build_unused_component_css_not_emitted() {
1524        // card-b is registered but not referenced in index.html
1525        let app = create_app_dir(&[
1526            ("index.html", "<card-a>A</card-a>"),
1527            ("card-a.html", "<div><slot></slot></div>"),
1528            ("card-a.css", ".a { color: red; }"),
1529            ("card-b.html", "<span><slot></slot></span>"),
1530            ("card-b.css", ".b { color: blue; }"),
1531        ]);
1532        let result = build(default_options(app.path())).unwrap();
1533
1534        assert_eq!(result.css_files.len(), 1);
1535        assert_eq!(result.css_files[0].0, "card-a.css");
1536    }
1537
1538    #[test]
1539    fn test_inspect_roundtrip_preserves_content() {
1540        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1541        let result = build(default_options(app.path())).unwrap();
1542
1543        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1544        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1545
1546        let fragments = &parsed["fragments"]["index.html"]["fragments"];
1547        assert!(fragments.is_array());
1548        assert!(!fragments.as_array().unwrap().is_empty());
1549    }
1550
1551    #[test]
1552    fn test_build_nonexistent_app_dir() {
1553        let options = BuildOptions {
1554            app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
1555            ..BuildOptions::default()
1556        };
1557        let result = build(options);
1558        assert!(result.is_err());
1559    }
1560
1561    #[test]
1562    fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
1563        let app = create_app_dir(&[
1564            ("index.html", "<my-card></my-card>"),
1565            ("my-card.html", "<div>Card</div>"),
1566            (
1567                "my-card.css",
1568                ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
1569            ),
1570        ]);
1571        let mut options = default_options(app.path());
1572        options.theme = Some(TokenFile {
1573            themes: HashMap::from([(
1574                "light".to_string(),
1575                HashMap::from([
1576                    ("token-a".to_string(), "red".to_string()),
1577                    ("token-b".to_string(), "blue".to_string()),
1578                    ("token-c".to_string(), "green".to_string()),
1579                ]),
1580            )]),
1581        });
1582
1583        let result = build(options).expect("all fallback tokens are defined");
1584        assert!(result.protocol.tokens.contains(&"token-a".to_string()));
1585        assert!(result.protocol.tokens.contains(&"token-b".to_string()));
1586        assert!(result.protocol.tokens.contains(&"token-c".to_string()));
1587    }
1588
1589    #[test]
1590    fn test_build_allows_literal_fallback_token_absent_from_theme() {
1591        let app = create_app_dir(&[
1592            ("index.html", "<my-card></my-card>"),
1593            ("my-card.html", "<div>Card</div>"),
1594            ("my-card.css", ":host { color: var(--brand, #000); }"),
1595        ]);
1596        let mut options = default_options(app.path());
1597        options.theme = Some(TokenFile {
1598            themes: HashMap::from([("light".to_string(), HashMap::new())]),
1599        });
1600
1601        // The CSS literal fallback (`#000`) means `--brand` is not required from
1602        // the theme, so the build must succeed — but the token is still hoisted
1603        // so the runtime resolves it when a theme provides it.
1604        let result = build(options).expect("literal fallback should not fail the build");
1605        assert!(result.protocol.tokens.contains(&"brand".to_string()));
1606        // `--brand` is absent from every theme and only used with a literal
1607        // fallback, so it is surfaced as a non-fatal typo advisory.
1608        assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
1609        assert!(result.warnings[0].body().contains("--brand"));
1610    }
1611
1612    #[test]
1613    fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
1614        // `--foo-bar` is defined in the entry's inline <style> after a signal
1615        // comment and consumed inside a component. Custom properties inherit
1616        // through Shadow DOM, so the ancestor definition must satisfy the
1617        // descendant and the token must not be required from the theme.
1618        let app = create_app_dir(&[
1619            (
1620                "index.html",
1621                "<html><head><style>:root {\n  /*{{{tokens.light}}}*/\n  --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
1622            ),
1623            ("my-card.html", "<div>Card</div>"),
1624            ("my-card.css", ":host { padding: var(--foo-bar); }"),
1625        ]);
1626        let mut options = default_options(app.path());
1627        options.theme = Some(TokenFile {
1628            themes: HashMap::from([("light".to_string(), HashMap::new())]),
1629        });
1630
1631        let result = build(options)
1632            .expect("ancestor inline-style definition (after a comment) should satisfy the child");
1633        assert!(
1634            !result.protocol.tokens.contains(&"foo-bar".to_string()),
1635            "ancestor-defined token should not be hoisted: {:?}",
1636            result.protocol.tokens
1637        );
1638    }
1639
1640    #[test]
1641    fn test_build_no_warning_when_literal_fallback_token_is_themed() {
1642        let app = create_app_dir(&[
1643            ("index.html", "<my-card></my-card>"),
1644            ("my-card.html", "<div>Card</div>"),
1645            ("my-card.css", ":host { color: var(--brand, #000); }"),
1646        ]);
1647        let mut options = default_options(app.path());
1648        options.theme = Some(TokenFile {
1649            themes: HashMap::from([(
1650                "light".to_string(),
1651                HashMap::from([("brand".to_string(), "#123456".to_string())]),
1652            )]),
1653        });
1654
1655        let result = build(options).expect("themed literal-fallback token builds");
1656        assert!(
1657            result.warnings.is_empty(),
1658            "a themed token must not warn: {:?}",
1659            result.warnings
1660        );
1661    }
1662
1663    #[test]
1664    fn test_build_without_theme_produces_no_warnings() {
1665        let app = create_app_dir(&[
1666            ("index.html", "<my-card></my-card>"),
1667            ("my-card.html", "<div>Card</div>"),
1668            ("my-card.css", ":host { color: var(--brand, #000); }"),
1669        ]);
1670        let result = build(default_options(app.path())).unwrap();
1671        assert!(result.warnings.is_empty());
1672    }
1673
1674    #[test]
1675    fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
1676        let app = create_app_dir(&[
1677            ("index.html", "<component-a></component-a>"),
1678            ("component-a.html", "<component-b></component-b>"),
1679            ("component-a.css", ":host { --brand-color: red; }"),
1680            ("component-b.html", "<div>Child</div>"),
1681            ("component-b.css", ":host { color: var(--brand-color); }"),
1682        ]);
1683        let mut options = default_options(app.path());
1684        options.theme = Some(TokenFile {
1685            themes: HashMap::from([("light".to_string(), HashMap::new())]),
1686        });
1687
1688        let result =
1689            build(options).expect("ancestor component custom property should satisfy child");
1690        assert!(
1691            !result.protocol.tokens.contains(&"brand-color".to_string()),
1692            "ancestor-defined token should not be exposed: {:?}",
1693            result.protocol.tokens
1694        );
1695    }
1696
1697    #[test]
1698    fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
1699        let app = create_app_dir(&[
1700            (
1701                "index.html",
1702                "<component-a></component-a><component-b></component-b>",
1703            ),
1704            ("component-a.html", "<div>A</div>"),
1705            ("component-a.css", ":host { --brand-color: red; }"),
1706            ("component-b.html", "<div>B</div>"),
1707            ("component-b.css", ":host { color: var(--brand-color); }"),
1708        ]);
1709        let mut options = default_options(app.path());
1710        options.theme = Some(TokenFile {
1711            themes: HashMap::from([("light".to_string(), HashMap::new())]),
1712        });
1713
1714        let Err(err) = build(options) else {
1715            panic!("sibling custom property must not satisfy component-b");
1716        };
1717        let message = err.chain_message();
1718        assert!(message.contains("missing theme token"), "msg: {message}");
1719        assert!(message.contains("missing-theme-token"), "msg: {message}");
1720        assert!(message.contains("--brand-color"), "msg: {message}");
1721    }
1722
1723    #[test]
1724    fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
1725        let app = create_app_dir(&[
1726            ("index.html", "<my-card></my-card>"),
1727            ("my-card.html", "<div>Card</div>"),
1728            (
1729                "my-card.css",
1730                ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
1731            ),
1732        ]);
1733        let mut options = default_options(app.path());
1734        options.theme = Some(TokenFile {
1735            themes: HashMap::from([(
1736                "light".to_string(),
1737                HashMap::from([("token-b".to_string(), "green".to_string())]),
1738            )]),
1739        });
1740
1741        let Err(err) = build(options) else {
1742            panic!("missing --token-c in the theme must fail the build");
1743        };
1744        let message = err.chain_message();
1745        assert!(message.contains("missing-theme-token"), "msg: {message}");
1746        assert!(message.contains("--token-c"), "msg: {message}");
1747        // The error demands the missing `--token-c`, never the locally-defined
1748        // `--token-a` (which only appears in the source snippet as context).
1749        assert!(!message.contains("add --token-a"), "msg: {message}");
1750    }
1751
1752    #[test]
1753    fn test_build_to_disk_returns_accurate_stats() {
1754        let app = create_app_dir(&[
1755            ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
1756            ("my-card.html", "<div><slot></slot></div>"),
1757            ("my-card.css", ".card { color: red; }"),
1758        ]);
1759        let out = TempDir::new().unwrap();
1760
1761        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1762
1763        assert!(stats.fragment_count > 0);
1764        assert_eq!(stats.css_file_count, 1);
1765        assert!(stats.component_count > 0);
1766        assert!(stats.protocol_size_bytes > 0);
1767        assert!(!stats.duration.is_zero());
1768    }
1769}