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;
25mod projection;
26pub mod server;
27pub mod streaming;
28
29pub use component_assets::{render_component_assets, ComponentAssetFile};
30pub use error::WebUIError;
31
32// Re-export core types from downstream crates
33pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory};
34pub use webui_handler::Result as HandlerResult;
35pub use webui_handler::{
36    plugin::HandlerPlugin, HandlerError, Protocol, RenderOptions, ResponseWriter, WebUIHandler,
37};
38pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
39pub use webui_parser::CssStrategy;
40pub use webui_parser::DomStrategy;
41pub use webui_parser::LegalComments;
42pub use webui_parser::ParserError;
43pub use webui_parser::ParserOptions;
44pub use webui_parser::Plugin;
45pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
46pub use webui_parser::{
47    AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
48};
49pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
50pub use webui_parser::{Diagnostic, Severity};
51pub use webui_protocol::WebUIProtocol;
52pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
53
54use std::collections::HashSet;
55use std::ffi::{OsStr, OsString};
56use std::fs;
57use std::path::Path;
58use std::time::Instant;
59use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
60use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
61use webui_parser::plugin::webui::WebUIParserPlugin;
62use webui_parser::plugin::ParserPluginArtifacts;
63use webui_parser::HtmlParser;
64
65/// Projection metadata validated once for reuse across many protocol builds.
66#[derive(Debug, Clone)]
67pub struct PreparedProjectionManifests {
68    snapshot: std::sync::Arc<projection::ProjectionSnapshot>,
69}
70
71#[derive(Debug)]
72enum ProjectionBarrierState {
73    Waiting,
74    Ready(PreparedProjectionManifests),
75    Failed(String),
76}
77
78/// A reusable projection source that waits for an orchestrated client build.
79#[doc(hidden)]
80#[derive(Debug, Clone)]
81pub struct PendingProjectionManifests {
82    state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
83}
84
85/// Completes a pending projection source exactly once.
86#[doc(hidden)]
87pub struct ProjectionManifestCompleter {
88    state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
89    completed: bool,
90}
91
92/// One projection manifest supplied to a build.
93#[derive(Debug, Clone)]
94pub enum ProjectionManifestSource {
95    /// Read and validate a manifest from disk.
96    Path(std::path::PathBuf),
97    /// Validate an already parsed/transported manifest JSON object.
98    ///
99    /// `manifest_path` is its logical disk location and anchors the manifest's
100    /// relative `root` plus stale input/output checks.
101    Inline {
102        /// Logical path the manifest would occupy on disk.
103        manifest_path: std::path::PathBuf,
104        /// Canonical manifest JSON.
105        json: String,
106    },
107    /// Reuse metadata that has already passed schema, hash, merge, and stale
108    /// validation.
109    Prepared(PreparedProjectionManifests),
110    /// Wait for an orchestrator to validate the completed client bundle.
111    #[doc(hidden)]
112    Pending(PendingProjectionManifests),
113}
114
115impl From<std::path::PathBuf> for ProjectionManifestSource {
116    fn from(path: std::path::PathBuf) -> Self {
117        Self::Path(path)
118    }
119}
120
121/// Validate and merge projection sources once for repeated builds.
122///
123/// This is intended for orchestrators such as `webui-press` that build many
124/// page protocols against one completed client bundle.
125pub fn prepare_projection_manifests(
126    sources: &[ProjectionManifestSource],
127) -> Result<PreparedProjectionManifests, WebUIError> {
128    let snapshot = projection::load_and_merge(sources)?
129        .unwrap_or_else(|| std::sync::Arc::new(projection::ProjectionSnapshot::default()));
130    Ok(PreparedProjectionManifests { snapshot })
131}
132
133/// Create a pending projection source and its one-shot completer.
134#[doc(hidden)]
135#[must_use]
136pub fn projection_manifest_barrier() -> (ProjectionManifestSource, ProjectionManifestCompleter) {
137    let state = std::sync::Arc::new((
138        std::sync::Mutex::new(ProjectionBarrierState::Waiting),
139        std::sync::Condvar::new(),
140    ));
141    (
142        ProjectionManifestSource::Pending(PendingProjectionManifests {
143            state: std::sync::Arc::clone(&state),
144        }),
145        ProjectionManifestCompleter {
146            state,
147            completed: false,
148        },
149    )
150}
151
152impl PendingProjectionManifests {
153    fn wait(&self) -> Result<PreparedProjectionManifests, WebUIError> {
154        let (lock, ready) = self.state.as_ref();
155        let mut state = lock.lock().map_err(|_| {
156            WebUIError::InvalidBuildOptions("projection synchronization lock poisoned".to_string())
157        })?;
158        loop {
159            match &*state {
160                ProjectionBarrierState::Waiting => {
161                    state = ready.wait(state).map_err(|_| {
162                        WebUIError::InvalidBuildOptions(
163                            "projection synchronization lock poisoned".to_string(),
164                        )
165                    })?;
166                }
167                ProjectionBarrierState::Ready(projection) => {
168                    return Ok(projection.clone());
169                }
170                ProjectionBarrierState::Failed(message) => {
171                    return Err(WebUIError::InvalidBuildOptions(message.clone()));
172                }
173            }
174        }
175    }
176}
177
178impl ProjectionManifestCompleter {
179    /// Wake waiting builds with validated metadata or a producer failure.
180    pub fn complete(mut self, result: std::result::Result<PreparedProjectionManifests, String>) {
181        let (lock, ready) = self.state.as_ref();
182        if let Ok(mut state) = lock.lock() {
183            *state = match result {
184                Ok(projection) => ProjectionBarrierState::Ready(projection),
185                Err(message) => ProjectionBarrierState::Failed(message),
186            };
187            self.completed = true;
188            ready.notify_all();
189        }
190    }
191}
192
193impl Drop for ProjectionManifestCompleter {
194    fn drop(&mut self) {
195        if self.completed {
196            return;
197        }
198        let (lock, ready) = self.state.as_ref();
199        if let Ok(mut state) = lock.lock() {
200            *state = ProjectionBarrierState::Failed(
201                "projection producer terminated before completing metadata".to_string(),
202            );
203            ready.notify_all();
204        }
205    }
206}
207
208/// Options for building a WebUI application.
209#[derive(Debug, Clone)]
210pub struct BuildOptions {
211    /// Path to the application folder containing templates.
212    pub app_dir: std::path::PathBuf,
213    /// Entry HTML file name (e.g., `"index.html"`).
214    pub entry: String,
215    /// CSS delivery strategy for component stylesheets.
216    pub css: CssStrategy,
217    /// DOM strategy for component rendering (shadow or light).
218    pub dom: DomStrategy,
219    /// Framework plugin to load.
220    pub plugin: Option<Plugin>,
221    /// Additional component sources (npm packages or local paths).
222    pub components: Vec<String>,
223    /// Additional root components to compile for static component asset emission.
224    ///
225    /// These roots are parsed into the protocol so their templates, styles, and
226    /// dependency closures can be emitted later, but they are not connected to
227    /// the entry fragment and therefore are not rendered during initial SSR.
228    pub component_asset_roots: Vec<String>,
229    /// Emitted asset filename template using `[name]`, `[hash]`, and `[ext]`.
230    ///
231    /// Applies to Link-mode CSS files and static component assets.
232    pub css_file_name_template: String,
233    /// Optional URL/base-path prefix for Link-mode `css_href` values.
234    /// When set, emitted protocol hrefs become `<base>/<filename>`.
235    pub css_public_base: Option<String>,
236    /// Legal comment preservation strategy.
237    pub legal_comments: LegalComments,
238    /// Optional loaded design-token theme used to validate discovered CSS tokens.
239    ///
240    /// When present, every discovered CSS token that remains unresolved after
241    /// local and ancestor CSS definitions are removed must exist in every theme,
242    /// unless the `var()` usage supplies a literal CSS fallback (e.g.
243    /// `var(--x, 16px)`), which provides its own value. Missing required tokens
244    /// fail the build as parser diagnostics.
245    pub theme: Option<TokenFile>,
246    /// Bundler-neutral state projection manifest fragments supplied as disk
247    /// paths or inline JSON with a logical manifest path.
248    ///
249    /// Empty (the default) means projection is disabled: the build emits
250    /// [`webui_protocol::InitialStateStrategy::Full`] and every scripted
251    /// component keeps a full-state (`All`) partial-navigation surface. No
252    /// JavaScript/TypeScript analysis ever runs in Rust.
253    ///
254    /// When non-empty, every manifest is loaded, hash-validated against the
255    /// files it declares, merged by component tag, and used to narrow each
256    /// scripted component that is actually compiled into the protocol down to
257    /// an exact hydration/navigation key surface. Every such scripted
258    /// component must have exactly one manifest entry, or the build fails
259    /// with [`WebUIError::Projection`] (`PROJ-B001`). Supplying manifests with
260    /// a `plugin` other than [`Plugin::WebUI`] fails the build immediately
261    /// (`PROJ-B002`): only the WebUI plugin produces protocol fields
262    /// compatible with per-component key encoding.
263    ///
264    /// Manifests are read only at build time. The runtime handler never
265    /// opens a manifest file; the compiled protocol is fully self-contained.
266    pub projection_manifests: Vec<ProjectionManifestSource>,
267}
268
269impl Default for BuildOptions {
270    fn default() -> Self {
271        Self {
272            app_dir: std::path::PathBuf::from("."),
273            entry: "index.html".to_string(),
274            css: CssStrategy::Link,
275            dom: DomStrategy::Shadow,
276            plugin: None,
277            components: Vec::new(),
278            component_asset_roots: Vec::new(),
279            css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
280            css_public_base: None,
281            legal_comments: LegalComments::default(),
282            theme: None,
283            projection_manifests: Vec::new(),
284        }
285    }
286}
287
288/// Statistics about a completed build.
289#[derive(Debug, Clone)]
290pub struct BuildStats {
291    /// Total wall-clock build time.
292    pub duration: std::time::Duration,
293    /// Total number of protocol fragments.
294    pub fragment_count: usize,
295    /// Number of registered components.
296    pub component_count: usize,
297    /// Number of CSS files produced.
298    pub css_file_count: usize,
299    /// Size of the serialized protocol in bytes.
300    pub protocol_size_bytes: usize,
301    /// Number of unique CSS tokens discovered.
302    pub token_count: usize,
303}
304
305/// Result of a successful build.
306#[derive(Debug)]
307pub struct BuildResult {
308    /// The compiled WebUI protocol.
309    pub protocol: WebUIProtocol,
310    /// Serialized protocol bytes (protobuf binary).
311    pub protocol_bytes: Vec<u8>,
312    /// Component CSS files: `(filename, content)` — only components referenced in the protocol.
313    pub css_files: Vec<(String, String)>,
314    /// Static component asset files: `(filename, ESM module content)`.
315    ///
316    /// Populated when [`BuildOptions::component_asset_roots`] is non-empty.
317    pub component_asset_files: Vec<ComponentAssetFile>,
318    /// Component client template payloads.
319    /// Includes templates for all components encountered during parsing,
320    /// including route-referenced components.
321    pub component_templates: Vec<ComponentTemplateArtifact>,
322    /// Non-fatal build advisories as warning-severity [`Diagnostic`]s.
323    ///
324    /// Currently surfaces CSS tokens that are referenced only with a literal
325    /// `var()` fallback and defined in no theme — often typos. Empty when no
326    /// theme is supplied. Carries the same structured location/snippet/`help:`
327    /// data as errors; the entry point decides how to present (and color) them.
328    pub warnings: Vec<Diagnostic>,
329    /// Build statistics.
330    pub stats: BuildStats,
331}
332
333/// Build a WebUI application from an app directory.
334///
335/// Parses templates, discovers components, and produces a compiled protocol
336/// with build statistics.
337///
338/// # Errors
339///
340/// Returns [`WebUIError`] if the app directory is invalid, templates fail
341/// to parse, theme tokens are incomplete, or the protocol cannot be serialized.
342#[must_use = "BuildResult contains the compiled protocol and statistics"]
343pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
344    let started = Instant::now();
345
346    let raw = build_protocol_inner(&options)?;
347
348    let protocol_bytes = raw.protocol.to_protobuf()?;
349
350    let stats = BuildStats {
351        duration: started.elapsed(),
352        fragment_count: raw.fragment_count,
353        component_count: raw.component_count,
354        css_file_count: raw.css_files.len(),
355        protocol_size_bytes: protocol_bytes.len(),
356        token_count: raw.token_count,
357    };
358
359    Ok(BuildResult {
360        protocol: raw.protocol,
361        protocol_bytes,
362        css_files: raw.css_files,
363        component_asset_files: raw.component_asset_files,
364        component_templates: raw.component_templates,
365        warnings: raw.warnings,
366        stats,
367    })
368}
369
370/// Build a WebUI application and write output files to disk.
371///
372/// Writes `protocol.bin`, any external CSS files, and static component assets
373/// to `out_dir`.
374/// Creates `out_dir` if it does not exist.
375///
376/// # Errors
377///
378/// Returns [`WebUIError`] on build failure or if output files cannot be written.
379pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
380    let result = build(options)?;
381    validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
382
383    fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
384        context: format!("Failed to create {}", out_dir.display()),
385        source,
386    })?;
387
388    fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
389        WebUIError::Io {
390            context: format!("Failed to write protocol.bin to {}", out_dir.display()),
391            source,
392        }
393    })?;
394
395    for (name, content) in &result.css_files {
396        fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
397            context: format!("Failed to write {name} to {}", out_dir.display()),
398            source,
399        })?;
400    }
401    for file in &result.component_asset_files {
402        fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
403            context: format!(
404                "Failed to write component asset {} to {}",
405                file.name,
406                out_dir.display()
407            ),
408            source,
409        })?;
410    }
411
412    Ok(result.stats)
413}
414
415/// Inspect a compiled WebUI protocol file and return its JSON representation.
416pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
417    let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
418        context: format!("Failed to read {}", protocol_path.display()),
419        source,
420    })?;
421    inspect_bytes(&bytes)
422}
423
424/// Inspect raw protocol bytes and return their JSON representation.
425pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
426    let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
427    protocol
428        .to_json_pretty()
429        .map_err(|e| WebUIError::Serialization(e.to_string()))
430}
431
432/// Internal intermediate build output before stats are computed.
433struct RawBuildOutput {
434    protocol: WebUIProtocol,
435    css_files: Vec<(String, String)>,
436    component_asset_files: Vec<ComponentAssetFile>,
437    component_templates: Vec<ComponentTemplateArtifact>,
438    warnings: Vec<Diagnostic>,
439    fragment_count: usize,
440    component_count: usize,
441    token_count: usize,
442}
443
444/// Internal build logic shared by `build()` and `build_to_disk()`.
445fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
446    let projection_enabled = !options.projection_manifests.is_empty();
447    if projection_enabled && options.plugin != Some(Plugin::WebUI) {
448        return Err(projection::incompatible_plugin_error());
449    }
450    let parser_options = ParserOptions::try_new(
451        options.css,
452        options.dom,
453        &options.css_file_name_template,
454        options.css_public_base.as_deref(),
455        options.legal_comments,
456    )
457    .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
458    let css_link_options = parser_options.css_link_options.clone();
459
460    let mut parser = match options.plugin {
461        Some(Plugin::Fast | Plugin::FastV2) => {
462            HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
463        }
464        Some(Plugin::FastV3) => {
465            HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
466        }
467        Some(Plugin::WebUI) => {
468            HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
469        }
470        None => HtmlParser::with_options(parser_options),
471    };
472
473    // Register app directory components
474    parser
475        .component_registry_mut()
476        .register_from_paths(&[&options.app_dir])
477        .map_err(|e| {
478            WebUIError::ComponentRegistration(format!(
479                "Failed to register components from {}: {e}",
480                options.app_dir.display()
481            ))
482        })?;
483
484    // Discover and register external component sources
485    for source in &options.components {
486        let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
487            WebUIError::ComponentDiscovery(format!(
488                "Failed to discover components from {source}: {e}"
489            ))
490        })?;
491        for comp in &result.components {
492            // Script presence marks client ownership. Rust never analyzes the
493            // source; exact state surfaces come from projection manifests.
494            parser
495                .component_registry_mut()
496                .register_component(webui_parser::ComponentRegistration {
497                    tag_name: &comp.tag_name,
498                    html_content: &comp.html_content,
499                    css_content: comp.css_content.as_deref(),
500                    is_client_owned: comp.is_client_owned,
501                })
502                .map_err(|e| {
503                    WebUIError::ComponentRegistration(format!(
504                        "Failed to register component '{}' from {}: {e}",
505                        comp.tag_name, comp.source
506                    ))
507                })?;
508        }
509    }
510
511    let component_count = parser.component_registry().len();
512
513    // Parse entry HTML
514    let entry_path = options.app_dir.join(&options.entry);
515    let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
516        context: format!("Failed to read {}", entry_path.display()),
517        source,
518    })?;
519    parser
520        .parse(&options.entry, &html_content)
521        .map_err(|source| WebUIError::Parse {
522            context: format!("Failed to parse {}", options.entry),
523            source,
524        })?;
525
526    let synthetic_asset_fragments =
527        parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
528
529    let css_snapshot: Vec<(String, String)> = parser
530        .component_registry()
531        .get_all()
532        .filter(|component| parser.has_fragment(&component.tag_name))
533        .filter_map(|component| {
534            component
535                .css_content
536                .as_ref()
537                .map(|css| (component.tag_name.clone(), css.clone()))
538        })
539        .collect();
540
541    // Collect CSS token analysis before consuming the parser.
542    let token_analysis = parser.token_analysis();
543    let mut warnings: Vec<Diagnostic> = Vec::new();
544    if let Some(theme) = options.theme.as_ref() {
545        token_analysis
546            .validate_theme_tokens(theme)
547            .map_err(|source| WebUIError::Parse {
548                context: "Failed to validate theme tokens".to_string(),
549                source,
550            })?;
551        // Advisory: a token used only as `var(--x, <literal>)` and absent from
552        // every theme is often a misspelling. The literal keeps the build green,
553        // so this is a warning (with a "did you mean …?" suggestion), not an
554        // error.
555        warnings = token_analysis.theme_token_warnings(theme);
556    }
557    let token_count = token_analysis.protocol_tokens.len();
558
559    let component_templates =
560        match parser
561            .take_plugin_artifacts()
562            .map_err(|source| WebUIError::Parse {
563                context: format!(
564                    "Failed to compile component templates for {}",
565                    options.entry
566                ),
567                source,
568            })? {
569            ParserPluginArtifacts::None => Vec::new(),
570            ParserPluginArtifacts::ComponentTemplates(templates) => templates,
571        };
572    // Build protocol (consumes parser)
573    let mut fragment_records = parser.into_fragment_records();
574    for fragment_id in synthetic_asset_fragments {
575        fragment_records.remove(&fragment_id);
576    }
577    let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
578
579    // Resolve projection only after template compilation. Ordinary path/inline
580    // builds pay the same validation cost, while orchestrators can overlap
581    // parser work with an in-flight client bundle through a pending source.
582    let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
583    let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
584    protocol.initial_state_strategy = if merged_manifest.is_some() {
585        webui_protocol::InitialStateStrategy::Components as i32
586    } else {
587        webui_protocol::InitialStateStrategy::Full as i32
588    };
589
590    // Strict coverage applies only to scripted components that actually made
591    // it into the compiled protocol/route closure (i.e. their fragment was
592    // emitted). Scripted components discovered but never compiled into a
593    // route/fragment do not require manifest coverage.
594    if let Some(merged) = &merged_manifest {
595        let compiled_scripted_tags: Vec<&str> = component_templates
596            .iter()
597            .filter(|artifact| {
598                artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
599            })
600            .map(|artifact| artifact.tag_name.as_str())
601            .collect();
602        projection::validate_coverage(&merged.components, &compiled_scripted_tags)?;
603    }
604
605    // Record build-wide strategies so the handler can decide rendering behavior.
606    protocol.set_css_strategy(match options.css {
607        CssStrategy::Link => webui_protocol::CssStrategy::Link,
608        CssStrategy::Style => webui_protocol::CssStrategy::Style,
609        CssStrategy::Module => webui_protocol::CssStrategy::Module,
610    });
611    protocol.set_dom_strategy(match options.dom {
612        DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
613        DomStrategy::Light => webui_protocol::DomStrategy::Light,
614    });
615
616    // Process component CSS in a single pass: store Module CSS content,
617    // set Link-strategy css_href, and collect external CSS files.
618    let is_module = options.css == CssStrategy::Module;
619    let is_link = options.css == CssStrategy::Link;
620    let mut css_files: Vec<(String, String)> = Vec::new();
621    let mut emitted_names: HashSet<String> = HashSet::new();
622    for (tag, css) in css_snapshot {
623        if !protocol.fragments.contains_key(&tag) {
624            continue;
625        }
626        if is_module {
627            protocol.components.entry(tag).or_default().css = css.trim().to_string();
628        } else if is_link {
629            let resolved = css_link_options.resolve(&tag, &css);
630            if !emitted_names.insert(resolved.filename.clone()) {
631                return Err(WebUIError::InvalidBuildOptions(format!(
632                    "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
633                    resolved.filename
634                )));
635            }
636            protocol.components.entry(tag).or_default().css_href = resolved.href;
637            css_files.push((resolved.filename, css));
638        }
639        // Style strategy: CSS is already baked into raw fragments by the
640        // parser — nothing to store in the protocol or emit as files.
641    }
642
643    // Store compiled client templates in the protocol so any host server can
644    // query them. Scriptless templates retain navigation metadata but contribute
645    // no initial hydration state.
646    for artifact in &component_templates {
647        let component = protocol
648            .components
649            .entry(artifact.tag_name.clone())
650            .or_default();
651        component.template = artifact.template.clone();
652        component.template_json = artifact.template_json.clone();
653        component.template_functions = artifact.template_functions.clone();
654        // Manifest keys are the initial hydration surface. Navigation is the
655        // union of manifest client keys and Rust-compiled template roots.
656        // Scriptless components are never governed by a manifest: they keep
657        // their Rust-derived surface regardless of projection.
658        let manifest_entry = if artifact.is_scripted {
659            merged_manifest
660                .as_ref()
661                .and_then(|merged| merged.components.get(&artifact.tag_name))
662        } else {
663            None
664        };
665        let (hydration_mode, hydration_keys) = match manifest_entry {
666            Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
667            None => encode_state_surface(&artifact.hydration),
668        };
669        component.hydration_mode = hydration_mode;
670        component.hydration_keys = hydration_keys;
671        let (navigation_mode, navigation_keys) = match manifest_entry {
672            Some(entry) => {
673                let union =
674                    projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
675                encode_state_surface(&StateSurface::Keys(union))
676            }
677            None => encode_state_surface(&artifact.navigation),
678        };
679        component.navigation_mode = navigation_mode;
680        component.navigation_keys = navigation_keys;
681    }
682
683    let component_asset_files = component_assets::render_component_assets(
684        &protocol,
685        &options.component_asset_roots,
686        &options.css_file_name_template,
687    )?;
688    validate_generated_file_names(&css_files, &component_asset_files)?;
689
690    Ok(RawBuildOutput {
691        protocol,
692        css_files,
693        component_asset_files,
694        component_templates,
695        warnings,
696        fragment_count,
697        component_count,
698        token_count,
699    })
700}
701
702fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
703    match surface {
704        StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
705        StateSurface::Keys(keys) => (
706            webui_protocol::StateProjectionMode::Keys as i32,
707            keys.clone(),
708        ),
709        StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
710    }
711}
712
713fn parse_component_asset_roots(
714    parser: &mut HtmlParser,
715    roots: &[String],
716) -> Result<Vec<String>, WebUIError> {
717    let mut synthetic_fragments = Vec::with_capacity(roots.len());
718    for (index, root) in roots.iter().enumerate() {
719        let root = root.trim();
720        if parser.has_fragment(root) {
721            continue;
722        }
723        // Compile the requested lazy root without connecting it to the entry
724        // graph, so it can be emitted as a static asset but stays out of SSR.
725        let mut synthetic = String::with_capacity(root.len() * 2 + 5);
726        synthetic.push('<');
727        synthetic.push_str(root);
728        synthetic.push_str("></");
729        synthetic.push_str(root);
730        synthetic.push('>');
731
732        let fragment_id = format!("__webui_asset_root_{index}");
733        parser
734            .parse(&fragment_id, &synthetic)
735            .map_err(|source| WebUIError::Parse {
736                context: format!("Failed to parse component asset root <{root}>"),
737                source,
738            })?;
739        synthetic_fragments.push(fragment_id);
740    }
741    Ok(synthetic_fragments)
742}
743
744fn validate_generated_file_names(
745    css_files: &[(String, String)],
746    component_asset_files: &[ComponentAssetFile],
747) -> Result<(), WebUIError> {
748    let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
749    for (name, _) in css_files {
750        names.insert(name.as_str());
751    }
752    for file in component_asset_files {
753        if !names.insert(file.name.as_str()) {
754            return Err(WebUIError::InvalidBuildOptions(format!(
755                "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
756                file.name
757            )));
758        }
759    }
760    Ok(())
761}
762
763fn validate_output_file_names(
764    protocol_name: &OsStr,
765    result: &BuildResult,
766) -> Result<(), WebUIError> {
767    let mut names =
768        HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
769    names.insert(protocol_name.to_os_string());
770    for (name, _) in &result.css_files {
771        let file_name = OsString::from(name);
772        if !names.insert(file_name.clone()) {
773            return Err(output_file_collision_error(&file_name));
774        }
775    }
776    for file in &result.component_asset_files {
777        let file_name = OsString::from(&file.name);
778        if !names.insert(file_name.clone()) {
779            return Err(output_file_collision_error(&file_name));
780        }
781    }
782    Ok(())
783}
784
785fn output_file_collision_error(name: &OsStr) -> WebUIError {
786    WebUIError::InvalidBuildOptions(format!(
787        "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
788        name.to_string_lossy()
789    ))
790}
791
792#[cfg(test)]
793#[allow(clippy::disallowed_methods)]
794mod tests {
795    use super::*;
796    use std::collections::HashMap;
797    use std::path::PathBuf;
798    use tempfile::TempDir;
799    use webui_handler::plugin::webui::WebUIHydrationPlugin;
800    use webui_protocol::web_ui_fragment::Fragment;
801
802    struct StringWriter {
803        buf: String,
804    }
805
806    impl ResponseWriter for StringWriter {
807        fn write(&mut self, content: &str) -> webui_handler::Result<()> {
808            self.buf.push_str(content);
809            Ok(())
810        }
811
812        fn end(&mut self) -> webui_handler::Result<()> {
813            Ok(())
814        }
815    }
816
817    fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
818        let dir = TempDir::new().unwrap();
819        for (name, content) in files {
820            let path = dir.path().join(name);
821            if let Some(parent) = path.parent() {
822                fs::create_dir_all(parent).unwrap();
823            }
824            fs::write(&path, content).unwrap();
825        }
826        dir
827    }
828
829    fn default_options(app_dir: &Path) -> BuildOptions {
830        BuildOptions {
831            app_dir: app_dir.to_path_buf(),
832            ..BuildOptions::default()
833        }
834    }
835
836    #[test]
837    fn test_build_simple_html() {
838        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
839        let result = build(default_options(app.path())).unwrap();
840
841        assert!(result.protocol.fragments.contains_key("index.html"));
842        assert!(result.stats.fragment_count > 0);
843        assert!(result.stats.protocol_size_bytes > 0);
844        assert!(!result.stats.duration.is_zero());
845        assert_eq!(
846            result.protocol.initial_state_strategy,
847            webui_protocol::InitialStateStrategy::Full as i32
848        );
849    }
850
851    #[test]
852    fn pending_projection_unblocks_after_template_compilation() {
853        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
854        let (source, completer) = projection_manifest_barrier();
855        let options = BuildOptions {
856            app_dir: app.path().to_path_buf(),
857            plugin: Some(Plugin::WebUI),
858            projection_manifests: vec![source],
859            ..BuildOptions::default()
860        };
861        let handle = std::thread::spawn(move || build(options));
862        let prepared = prepare_projection_manifests(&[]).unwrap();
863        completer.complete(Ok(prepared));
864        let result = handle.join().unwrap().unwrap();
865        assert_eq!(
866            result.protocol.initial_state_strategy,
867            webui_protocol::InitialStateStrategy::Components as i32
868        );
869    }
870
871    #[test]
872    fn test_build_keeps_scriptless_component_dormant_until_client_use() {
873        let app = create_app_dir(&[
874            (
875                "index.html",
876                "<html><body><demo-card></demo-card></body></html>",
877            ),
878            ("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
879        ]);
880        // An empty manifest fragment (no scripted components exist in this
881        // app, so strict coverage trivially passes) still turns on
882        // `InitialStateStrategy::Components`, letting scriptless components'
883        // Rust-derived dormant surface (`None` hydration) take effect.
884        let manifest_json =
885            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
886        let manifest = projection::test_support::write_manifest(
887            app.path(),
888            "webui-projection.json",
889            &manifest_json,
890        );
891        let mut options = default_options(app.path());
892        options.plugin = Some(Plugin::WebUI);
893        options.projection_manifests = vec![manifest.into()];
894        let result = build(options).unwrap();
895        assert_eq!(
896            result.protocol.initial_state_strategy,
897            webui_protocol::InitialStateStrategy::Components as i32
898        );
899
900        assert!(result
901            .component_templates
902            .iter()
903            .any(|template| template.tag_name == "demo-card"));
904        let component = result
905            .protocol
906            .components
907            .get("demo-card")
908            .expect("scriptless component metadata should be retained");
909        assert!(component.hydration_keys.is_empty());
910        assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
911        assert!(component.template_json.contains(r#""th":1"#));
912
913        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
914        let state = serde_json::json!({ "name": "Server rendered" });
915        let protocol = Protocol::new(result.protocol.clone());
916        let mut writer = StringWriter { buf: String::new() };
917        handler
918            .render(
919                &protocol,
920                &state,
921                &RenderOptions::new("index.html", "/"),
922                &mut writer,
923            )
924            .unwrap();
925        assert!(writer.buf.contains("Server rendered"));
926        assert!(writer.buf.contains(r#""state":{}"#));
927        assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
928    }
929
930    #[test]
931    fn test_build_treats_sibling_component_script_as_scripted() {
932        let app = create_app_dir(&[
933            ("index.html", "<demo-card></demo-card>"),
934            (
935                "demo-card.html",
936                r#"<button @click="{onClick()}">{{name}}</button>"#,
937            ),
938            (
939                "demo-card.ts",
940                "import { WebUIElement } from '@microsoft/webui-framework';\n\
941                 class DemoCard extends WebUIElement { onClick() {} }\n\
942                 DemoCard.define('demo-card');",
943            ),
944        ]);
945        let mut options = default_options(app.path());
946        options.plugin = Some(Plugin::WebUI);
947        let result = build(options).unwrap();
948
949        let template = result
950            .component_templates
951            .iter()
952            .find(|template| template.tag_name == "demo-card")
953            .unwrap();
954        assert!(template.template_json.contains(r#""eg":[["click""#));
955    }
956
957    #[test]
958    fn test_build_populates_component_hydration_keys_from_script() {
959        let app = create_app_dir(&[
960            ("index.html", "<demo-card></demo-card>"),
961            ("demo-card.html", "<p>{{name}}</p>"),
962            (
963                "demo-card.ts",
964                "import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
965                 class DemoCard extends WebUIElement {\n\
966                 @observable name = '';\n\
967                 @attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
968                 @observable count = 0;\n\
969                 }\n\
970                 DemoCard.define('demo-card');",
971            ),
972        ]);
973        // Rust performs no JavaScript/TypeScript analysis: the exact keys
974        // come from a bundler-produced projection manifest, not from
975        // scanning `demo-card.ts`.
976        let manifest_json = projection::test_support::build_valid_manifest_json(
977            app.path(),
978            &[("demo-card.ts", "source")],
979            &[("demo-card.js", "output")],
980            &[(
981                "demo-card",
982                "demo-card.ts",
983                &["demo-card.js"],
984                &["count", "ctaHref", "name"],
985                &["count", "ctaHref", "name"],
986            )],
987        );
988        let manifest = projection::test_support::write_manifest(
989            app.path(),
990            "webui-projection.json",
991            &manifest_json,
992        );
993        let mut options = default_options(app.path());
994        options.plugin = Some(Plugin::WebUI);
995        options.projection_manifests = vec![manifest.into()];
996        let result = build(options).unwrap();
997
998        assert_eq!(
999            result.protocol.initial_state_strategy,
1000            webui_protocol::InitialStateStrategy::Components as i32
1001        );
1002        let component = result.protocol.components.get("demo-card").unwrap();
1003        assert_eq!(
1004            component.hydration_mode,
1005            webui_protocol::StateProjectionMode::Keys as i32
1006        );
1007        assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
1008        assert_eq!(
1009            component.navigation_mode,
1010            webui_protocol::StateProjectionMode::Keys as i32
1011        );
1012        assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
1013    }
1014
1015    #[test]
1016    fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
1017        let app = create_app_dir(&[
1018            (
1019                "index.html",
1020                "<html><body><demo-card></demo-card></body></html>",
1021            ),
1022            ("demo-card.html", "<p>{{name}}</p>"),
1023            (
1024                "demo-card.ts",
1025                "import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
1026                 class DemoCard extends WebUIElement {\n\
1027                 @observable name = '';\n\
1028                 }\n\
1029                 DemoCard.define('demo-card');",
1030            ),
1031        ]);
1032        let mut options = default_options(app.path());
1033        options.plugin = Some(Plugin::WebUI);
1034        // No `projection_manifests` supplied: without a manifest, Rust never
1035        // analyzes `demo-card.ts` and must assume the full, unproven surface.
1036        let result = build(options).unwrap();
1037
1038        assert_eq!(
1039            result.protocol.initial_state_strategy,
1040            webui_protocol::InitialStateStrategy::Full as i32
1041        );
1042        let component = result.protocol.components.get("demo-card").unwrap();
1043        assert_eq!(
1044            component.hydration_mode,
1045            webui_protocol::StateProjectionMode::All as i32
1046        );
1047        assert!(component.hydration_keys.is_empty());
1048        assert_eq!(
1049            component.navigation_mode,
1050            webui_protocol::StateProjectionMode::All as i32
1051        );
1052
1053        let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1054        let state = serde_json::json!({ "name": "Visible" });
1055        let protocol = Protocol::new(result.protocol.clone());
1056        let mut writer = StringWriter { buf: String::new() };
1057        handler
1058            .render(
1059                &protocol,
1060                &state,
1061                &RenderOptions::new("index.html", "/"),
1062                &mut writer,
1063            )
1064            .unwrap();
1065        assert!(writer.buf.contains("Visible"));
1066        // Full-state fallback serializes the entire supplied state object,
1067        // not a narrowed key subset.
1068        assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
1069    }
1070
1071    #[test]
1072    fn test_build_rejects_scriptless_event_component() {
1073        let app = create_app_dir(&[
1074            ("index.html", "<demo-card></demo-card>"),
1075            (
1076                "demo-card.html",
1077                r#"<button @click="{onClick()}">Click</button>"#,
1078            ),
1079        ]);
1080        let mut options = default_options(app.path());
1081        options.plugin = Some(Plugin::WebUI);
1082        let err = build(options).expect_err("scriptless events should fail");
1083
1084        let message = err.chain_message();
1085        assert!(
1086            message.contains("scriptless-event-handler"),
1087            "msg: {message}"
1088        );
1089    }
1090
1091    #[test]
1092    fn test_build_returns_actionable_error_for_invalid_w_ref() {
1093        let app = create_app_dir(&[
1094            ("index.html", "<my-card>Hello</my-card>"),
1095            ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
1096        ]);
1097        let mut options = default_options(app.path());
1098        options.plugin = Some(Plugin::WebUI);
1099
1100        // Authoring mistakes are returned (not panicked) so every consumer —
1101        // CLI, Node, FFI, WASM — can surface the message its own way.
1102        let Err(err) = build(options) else {
1103            panic!("non-braced w-ref must fail the build");
1104        };
1105        let msg = err.chain_message();
1106        assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
1107        assert!(
1108            msg.contains("component <my-card> · element <input>"),
1109            "msg: {msg}"
1110        );
1111        assert!(msg.contains("help:"), "msg: {msg}");
1112    }
1113
1114    #[test]
1115    fn test_build_returns_actionable_error_for_invalid_event_handler() {
1116        let app = create_app_dir(&[
1117            ("index.html", "<my-btn>x</my-btn>"),
1118            (
1119                "my-btn.html",
1120                r#"<div><button @click="e.preventDefault()">x</button></div>"#,
1121            ),
1122        ]);
1123        let mut options = default_options(app.path());
1124        options.plugin = Some(Plugin::WebUI);
1125
1126        let Err(err) = build(options) else {
1127            panic!("invalid @event handler must fail the build");
1128        };
1129        let msg = err.chain_message();
1130        assert!(msg.contains("invalid @click handler"), "msg: {msg}");
1131        assert!(
1132            msg.contains("component <my-btn> · element <button>"),
1133            "msg: {msg}"
1134        );
1135    }
1136
1137    #[test]
1138    fn test_error_chain_message_has_no_duplicate_source() {
1139        let err = WebUIError::Parse {
1140            context: "Failed to parse index.html".to_string(),
1141            source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
1142        };
1143        // Each layer's Display describes only its own level — the source is not
1144        // embedded — so anyhow's `{:#}` / chain walks never repeat it.
1145        assert_eq!(err.to_string(), "Failed to parse index.html");
1146        // The flat message includes the source exactly once (no repetition).
1147        assert_eq!(
1148            err.chain_message(),
1149            "Failed to parse index.html: Directive error: Invalid for each: x"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_build_with_directives() {
1155        let html = r#"<h1>Hello</h1>
1156<for each="item in items">
1157    <p>{{item.name}}</p>
1158</for>
1159<if condition="show">
1160    <p>Visible</p>
1161</if>"#;
1162        let app = create_app_dir(&[("index.html", html)]);
1163        let result = build(default_options(app.path())).unwrap();
1164
1165        let index = &result.protocol.fragments["index.html"].fragments;
1166        assert!(index
1167            .iter()
1168            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
1169        assert!(index
1170            .iter()
1171            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1172    }
1173
1174    #[test]
1175    fn test_build_with_component_css() {
1176        let app = create_app_dir(&[
1177            ("index.html", "<my-card>Hello</my-card>"),
1178            ("my-card.html", "<div><slot></slot></div>"),
1179            ("my-card.css", ".card { color: red; }"),
1180            ("my-card.ts", "export {};"),
1181        ]);
1182        let result = build(default_options(app.path())).unwrap();
1183
1184        assert_eq!(result.css_files.len(), 1);
1185        assert_eq!(result.css_files[0].0, "my-card.css");
1186        assert!(result.css_files[0].1.contains("color: red"));
1187        assert_eq!(result.stats.css_file_count, 1);
1188    }
1189
1190    #[test]
1191    fn test_build_strips_non_legal_css_comments_from_output_file() {
1192        let app = create_app_dir(&[
1193            ("index.html", "<my-card>Hello</my-card>"),
1194            ("my-card.html", "<div><slot></slot></div>"),
1195            (
1196                "my-card.css",
1197                "/* remove var(--ignored) */ .card { color: var(--textColor); }",
1198            ),
1199        ]);
1200        let result = build(default_options(app.path())).unwrap();
1201
1202        assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
1203        assert_eq!(result.protocol.tokens, vec!["textColor"]);
1204    }
1205
1206    #[test]
1207    fn test_build_preserves_legal_css_comments_by_default() {
1208        let app = create_app_dir(&[
1209            ("index.html", "<my-card>Hello</my-card>"),
1210            ("my-card.html", "<div><slot></slot></div>"),
1211            (
1212                "my-card.css",
1213                "/*! @license MIT */ .card { color: red; } /* remove */",
1214            ),
1215        ]);
1216        let result = build(default_options(app.path())).unwrap();
1217
1218        assert_eq!(
1219            result.css_files[0].1,
1220            "/*! @license MIT */ .card { color: red; } "
1221        );
1222    }
1223
1224    #[test]
1225    fn test_build_legal_comments_none_strips_legal_css_comments() {
1226        let app = create_app_dir(&[
1227            ("index.html", "<my-card>Hello</my-card>"),
1228            ("my-card.html", "<div><slot></slot></div>"),
1229            ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
1230        ]);
1231        let mut options = default_options(app.path());
1232        options.legal_comments = LegalComments::None;
1233        let result = build(options).unwrap();
1234
1235        assert_eq!(result.css_files[0].1, " .card { color: red; }");
1236    }
1237
1238    #[test]
1239    fn test_build_with_css_hashed_filename_template() {
1240        let app = create_app_dir(&[
1241            ("index.html", "<my-card>Hello</my-card>"),
1242            ("my-card.html", "<div><slot></slot></div>"),
1243            ("my-card.css", ".card { color: red; }"),
1244        ]);
1245        let mut options = default_options(app.path());
1246        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1247        let result = build(options).unwrap();
1248
1249        assert_eq!(result.css_files.len(), 1);
1250        let generated = &result.css_files[0].0;
1251        assert!(generated.starts_with("my-card-"));
1252        assert!(generated.ends_with(".css"));
1253        assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
1254        assert_eq!(
1255            result
1256                .protocol
1257                .components
1258                .get("my-card")
1259                .map(|c| c.css_href.as_str()),
1260            Some(generated.as_str())
1261        );
1262    }
1263
1264    #[test]
1265    fn test_css_public_base_prefixes_css_href_only() {
1266        let app = create_app_dir(&[
1267            ("index.html", "<my-card>Hello</my-card>"),
1268            ("my-card.html", "<div><slot></slot></div>"),
1269            ("my-card.css", ".card { color: red; }"),
1270        ]);
1271        let mut options = default_options(app.path());
1272        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1273        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1274        let result = build(options).unwrap();
1275
1276        let filename = &result.css_files[0].0;
1277        assert!(filename.starts_with("my-card-"));
1278        let expected_href = format!("https://cdn.example.com/assets/{filename}");
1279        assert_eq!(
1280            result
1281                .protocol
1282                .components
1283                .get("my-card")
1284                .map(|c| c.css_href.as_str()),
1285            Some(expected_href.as_str())
1286        );
1287    }
1288
1289    #[test]
1290    fn test_css_public_base_emits_parser_and_handler_links() {
1291        let app = create_app_dir(&[
1292            (
1293                "index.html",
1294                "<html><head></head><body><my-card>Hello</my-card></body></html>",
1295            ),
1296            ("my-card.html", "<div><slot></slot></div>"),
1297            ("my-card.css", ".card { color: red; }"),
1298        ]);
1299        let mut options = default_options(app.path());
1300        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1301        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1302        let result = build(options).unwrap();
1303
1304        let filename = &result.css_files[0].0;
1305        let expected_href = format!("https://cdn.example.com/assets/{filename}");
1306        let component_html = result.protocol.fragments["my-card"]
1307            .fragments
1308            .iter()
1309            .filter_map(|fragment| match fragment.fragment.as_ref() {
1310                Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1311                _ => None,
1312            })
1313            .collect::<String>();
1314        assert!(
1315            component_html.contains(&format!(
1316                r#"<link rel="stylesheet" href="{expected_href}">"#
1317            )),
1318            "parser-generated component template should use CDN href: {component_html}"
1319        );
1320
1321        let handler = WebUIHandler::new();
1322        let protocol = Protocol::new(result.protocol.clone());
1323        let mut writer = StringWriter { buf: String::new() };
1324        handler
1325            .render(
1326                &protocol,
1327                &serde_json::json!({}),
1328                &RenderOptions::new("index.html", "/"),
1329                &mut writer,
1330            )
1331            .unwrap();
1332        assert!(
1333            writer.buf.contains(&format!(
1334                r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
1335            )),
1336            "handler head preload should use CDN href: {}",
1337            writer.buf
1338        );
1339    }
1340
1341    #[test]
1342    fn test_css_public_base_emits_webui_plugin_template_links() {
1343        let app = create_app_dir(&[
1344            ("index.html", "<my-card>Hello</my-card>"),
1345            ("my-card.html", "<div><slot></slot></div>"),
1346            ("my-card.css", ".card { color: red; }"),
1347            ("my-card.ts", "export {};"),
1348        ]);
1349        let mut options = default_options(app.path());
1350        options.plugin = Some(Plugin::WebUI);
1351        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1352        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1353        let result = build(options).unwrap();
1354
1355        let filename = &result.css_files[0].0;
1356        let expected_href = format!("https://cdn.example.com/assets/{filename}");
1357        let template = &result.protocol.components["my-card"].template_json;
1358        assert!(
1359            template.contains(&format!(r#"href=\"{expected_href}\""#)),
1360            "plugin component template should use CDN href: {template}"
1361        );
1362    }
1363
1364    #[test]
1365    fn test_css_public_base_emits_fast_plugin_template_links() {
1366        let app = create_app_dir(&[
1367            ("index.html", "<my-card>Hello</my-card>"),
1368            ("my-card.html", "<div><slot></slot></div>"),
1369            ("my-card.css", ".card { color: red; }"),
1370        ]);
1371        let mut options = default_options(app.path());
1372        options.plugin = Some(Plugin::FastV3);
1373        options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1374        options.css_public_base = Some("https://cdn.example.com/assets".to_string());
1375        let result = build(options).unwrap();
1376
1377        let filename = &result.css_files[0].0;
1378        let expected_href = format!("https://cdn.example.com/assets/{filename}");
1379        let template = &result.protocol.components["my-card"].template;
1380        assert!(
1381            template.contains(&format!(r#"href="{expected_href}""#)),
1382            "FAST component template should use CDN href: {template}"
1383        );
1384    }
1385
1386    #[test]
1387    fn test_invalid_css_template_is_rejected() {
1388        let app = create_app_dir(&[
1389            ("index.html", "<my-card>Hello</my-card>"),
1390            ("my-card.html", "<div><slot></slot></div>"),
1391            ("my-card.css", ".card { color: red; }"),
1392        ]);
1393        let mut options = default_options(app.path());
1394        options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
1395        let result = build(options);
1396
1397        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1398    }
1399
1400    #[test]
1401    fn test_css_filename_collision_is_rejected() {
1402        let app = create_app_dir(&[
1403            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1404            ("card-a.html", "<div><slot></slot></div>"),
1405            ("card-a.css", ".x { color: red; }"),
1406            ("card-b.html", "<div><slot></slot></div>"),
1407            ("card-b.css", ".x { color: red; }"),
1408        ]);
1409        let mut options = default_options(app.path());
1410        options.css_file_name_template = "[hash].[ext]".to_string();
1411        let result = build(options);
1412
1413        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1414    }
1415
1416    #[test]
1417    fn test_build_returns_static_component_asset_files() {
1418        let app = create_app_dir(&[
1419            ("index.html", "<app-shell></app-shell>"),
1420            ("app-shell.html", "<div></div>"),
1421            (
1422                "lazy-panel.html",
1423                r#"<if condition="ready"><p>{{title}}</p></if>"#,
1424            ),
1425            ("lazy-panel.ts", "export {};"),
1426        ]);
1427        let mut options = default_options(app.path());
1428        options.plugin = Some(Plugin::WebUI);
1429        options.component_asset_roots = vec!["lazy-panel".to_string()];
1430
1431        let result = build(options).unwrap();
1432
1433        assert_eq!(result.component_asset_files.len(), 1);
1434        assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
1435        assert!(result.component_asset_files[0]
1436            .content
1437            .contains(r#""type":"webui-component-asset""#));
1438        assert!(result.component_asset_files[0]
1439            .content
1440            .contains(r#""templateFunctions":{"lazy-panel":"#));
1441        assert!(result.protocol.fragments.contains_key("lazy-panel"));
1442        assert!(
1443            !result
1444                .protocol
1445                .fragments
1446                .keys()
1447                .any(|key| key.starts_with("__webui_asset_root_")),
1448            "synthetic asset root fragments must not be serialized"
1449        );
1450    }
1451
1452    #[test]
1453    fn test_component_asset_filename_collision_with_css_is_rejected() {
1454        let app = create_app_dir(&[
1455            ("index.html", "<app-shell></app-shell>"),
1456            ("app-shell.html", "<div></div>"),
1457            ("lazy-panel.html", "<p>Lazy</p>"),
1458            ("lazy-panel.css", ".panel { color: red; }"),
1459        ]);
1460        let mut options = default_options(app.path());
1461        options.plugin = Some(Plugin::WebUI);
1462        options.component_asset_roots = vec!["lazy-panel".to_string()];
1463        options.css_file_name_template = "[name]".to_string();
1464
1465        let result = build(options);
1466
1467        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1468    }
1469
1470    #[test]
1471    fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
1472        let app = create_app_dir(&[
1473            ("index.html", "<app-shell></app-shell>"),
1474            ("app-shell.html", "<div></div>"),
1475            ("lazy-panel.html", "<p>Lazy</p>"),
1476        ]);
1477        let out = TempDir::new().unwrap();
1478        let mut options = default_options(app.path());
1479        options.plugin = Some(Plugin::WebUI);
1480        options.component_asset_roots = vec!["lazy-panel".to_string()];
1481        options.css_file_name_template = "protocol.bin".to_string();
1482
1483        let result = build_to_disk(options, out.path());
1484
1485        assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1486        assert!(!out.path().join("protocol.bin").exists());
1487    }
1488
1489    #[test]
1490    fn test_css_href_set_for_light_dom_link_strategy() {
1491        let app = create_app_dir(&[
1492            ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
1493            ("has-css.html", "<p><slot></slot></p>"),
1494            ("has-css.css", ".yes { color: green; }"),
1495            ("no-css.html", "<p><slot></slot></p>"),
1496        ]);
1497        let mut options = default_options(app.path());
1498        options.dom = DomStrategy::Light;
1499        let result = build(options).unwrap();
1500
1501        let href = result
1502            .protocol
1503            .components
1504            .get("has-css")
1505            .map(|c| c.css_href.as_str())
1506            .unwrap_or("");
1507        assert_eq!(
1508            href, "has-css.css",
1509            "Light×Link component with CSS should have css_href"
1510        );
1511
1512        let no_href = result
1513            .protocol
1514            .components
1515            .get("no-css")
1516            .map(|c| c.css_href.as_str())
1517            .unwrap_or("");
1518        assert!(
1519            no_href.is_empty(),
1520            "Component without CSS should have empty css_href"
1521        );
1522    }
1523
1524    #[test]
1525    fn test_shadow_dom_link_strategy_sets_css_href() {
1526        // Shadow×Link: css_href is always set for Link-strategy components.
1527        // The handler uses protocol.css_strategy + dom_strategy to decide
1528        // whether to emit preload (Shadow) or stylesheet (Light) in <head>.
1529        let app = create_app_dir(&[
1530            ("index.html", "<my-card>A</my-card>"),
1531            ("my-card.html", "<p><slot></slot></p>"),
1532            ("my-card.css", ".card { color: red; }"),
1533        ]);
1534        let result = build(default_options(app.path())).unwrap();
1535
1536        let comp = result.protocol.components.get("my-card").unwrap();
1537        assert_eq!(
1538            comp.css_href, "my-card.css",
1539            "Shadow×Link should set css_href"
1540        );
1541
1542        // Strategy fields on protocol
1543        assert_eq!(
1544            result.protocol.css_strategy(),
1545            webui_protocol::CssStrategy::Link,
1546        );
1547        assert_eq!(
1548            result.protocol.dom_strategy(),
1549            webui_protocol::DomStrategy::Shadow,
1550        );
1551
1552        // CSS file should still be emitted for the server to serve
1553        assert_eq!(
1554            result.css_files.len(),
1555            1,
1556            "CSS file should still be produced"
1557        );
1558    }
1559
1560    #[test]
1561    fn test_css_href_empty_for_style_strategy() {
1562        let app = create_app_dir(&[
1563            ("index.html", "<my-card>A</my-card>"),
1564            ("my-card.html", "<p><slot></slot></p>"),
1565            ("my-card.css", ".card { color: red; }"),
1566        ]);
1567        let mut options = default_options(app.path());
1568        options.css = CssStrategy::Style;
1569        let result = build(options).unwrap();
1570
1571        let href = result
1572            .protocol
1573            .components
1574            .get("my-card")
1575            .map(|c| c.css_href.as_str())
1576            .unwrap_or("");
1577        assert!(href.is_empty(), "Style-strategy should not set css_href");
1578    }
1579
1580    #[test]
1581    fn test_css_href_empty_for_module_strategy() {
1582        let app = create_app_dir(&[
1583            ("index.html", "<my-card>A</my-card>"),
1584            ("my-card.html", "<p><slot></slot></p>"),
1585            ("my-card.css", ".card { color: red; }"),
1586        ]);
1587        let mut options = default_options(app.path());
1588        options.css = CssStrategy::Module;
1589        let result = build(options).unwrap();
1590
1591        let href = result
1592            .protocol
1593            .components
1594            .get("my-card")
1595            .map(|c| c.css_href.as_str())
1596            .unwrap_or("");
1597        assert!(href.is_empty(), "Module-strategy should not set css_href");
1598        assert!(
1599            !result.protocol.components["my-card"].css.is_empty(),
1600            "Module-strategy should still populate css content"
1601        );
1602    }
1603
1604    #[test]
1605    fn test_build_to_disk_writes_files() {
1606        let app = create_app_dir(&[
1607            ("index.html", "<my-card>Hello</my-card>"),
1608            ("my-card.html", "<div><slot></slot></div>"),
1609            ("my-card.css", ".card { color: red; }"),
1610            ("my-card.ts", "export {};"),
1611        ]);
1612        let out = TempDir::new().unwrap();
1613
1614        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1615
1616        assert!(out.path().join("protocol.bin").exists());
1617        assert!(out.path().join("my-card.css").exists());
1618        assert_eq!(stats.css_file_count, 1);
1619        assert!(stats.fragment_count > 0);
1620    }
1621
1622    #[test]
1623    fn test_build_to_disk_creates_nested_output_dir() {
1624        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1625        let out = TempDir::new().unwrap();
1626        let nested = out.path().join("nested").join("output");
1627
1628        let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
1629        assert!(nested.join("protocol.bin").exists());
1630        assert!(stats.fragment_count > 0);
1631    }
1632
1633    #[test]
1634    fn test_build_missing_entry() {
1635        let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
1636        let result = build(default_options(app.path()));
1637
1638        assert!(result.is_err());
1639        let err = result.unwrap_err();
1640        assert!(matches!(err, WebUIError::Io { .. }));
1641    }
1642
1643    #[test]
1644    fn test_build_stats_populated() {
1645        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1646        let result = build(default_options(app.path())).unwrap();
1647
1648        assert!(result.stats.fragment_count > 0);
1649        assert!(result.stats.protocol_size_bytes > 0);
1650        assert_eq!(result.stats.css_file_count, 0);
1651    }
1652
1653    #[test]
1654    fn test_build_inline_css() {
1655        let app = create_app_dir(&[
1656            ("index.html", "<my-card>Hello</my-card>"),
1657            ("my-card.html", "<div><slot></slot></div>"),
1658            ("my-card.css", ".card { color: red; }"),
1659        ]);
1660        let mut options = default_options(app.path());
1661        options.css = CssStrategy::Style;
1662
1663        let result = build(options).unwrap();
1664        // Inline mode embeds CSS in <style> tags — no external CSS files
1665        assert!(result.css_files.is_empty());
1666        assert_eq!(result.stats.css_file_count, 0);
1667        assert!(result.stats.fragment_count > 0);
1668    }
1669
1670    #[test]
1671    fn test_build_module_css() {
1672        let app = create_app_dir(&[
1673            ("index.html", "<my-card>Hello</my-card>"),
1674            ("my-card.html", "<div><slot></slot></div>"),
1675            ("my-card.css", ".card { color: red; }"),
1676        ]);
1677        let mut options = default_options(app.path());
1678        options.css = CssStrategy::Module;
1679
1680        let result = build(options).unwrap();
1681        // Module mode emits <script type="importmap"> data URIs — no external CSS files
1682        assert!(result.css_files.is_empty());
1683        assert_eq!(result.stats.css_file_count, 0);
1684        assert!(result.stats.fragment_count > 0);
1685    }
1686
1687    #[test]
1688    fn test_build_custom_entry() {
1689        let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
1690        let mut options = default_options(app.path());
1691        options.entry = "page.html".to_string();
1692
1693        let result = build(options).unwrap();
1694        assert!(result.protocol.fragments.contains_key("page.html"));
1695        assert!(!result.protocol.fragments.contains_key("index.html"));
1696    }
1697
1698    #[test]
1699    fn test_build_protocol_roundtrip() {
1700        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1701        let result = build(default_options(app.path())).unwrap();
1702
1703        let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1704        assert!(restored.fragments.contains_key("index.html"));
1705    }
1706
1707    #[test]
1708    fn protocol_is_available_from_facade() {
1709        let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
1710        assert!(protocol.tokens().is_empty());
1711    }
1712
1713    #[test]
1714    fn test_inspect_bytes_valid() {
1715        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1716        let result = build(default_options(app.path())).unwrap();
1717
1718        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1719        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1720        assert!(parsed.get("fragments").is_some());
1721    }
1722
1723    #[test]
1724    fn test_inspect_bytes_invalid() {
1725        let result = inspect_bytes(b"not a protobuf");
1726        assert!(result.is_err());
1727        assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
1728    }
1729
1730    #[test]
1731    fn test_inspect_file() {
1732        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1733        let out = TempDir::new().unwrap();
1734        build_to_disk(default_options(app.path()), out.path()).unwrap();
1735
1736        let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
1737        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1738        assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
1739    }
1740
1741    #[test]
1742    fn test_inspect_missing_file() {
1743        let result = inspect(Path::new("/nonexistent/protocol.bin"));
1744        assert!(result.is_err());
1745        assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
1746    }
1747
1748    #[test]
1749    fn test_build_with_components_local_path() {
1750        let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
1751        let ext_dir = TempDir::new().unwrap();
1752        fs::write(
1753            ext_dir.path().join("ext-card.html"),
1754            "<div class=\"card\"><slot></slot></div>",
1755        )
1756        .unwrap();
1757        fs::write(
1758            ext_dir.path().join("ext-card.css"),
1759            ".card { border: 1px solid #ccc; }",
1760        )
1761        .unwrap();
1762
1763        let mut options = default_options(app.path());
1764        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1765
1766        let result = build(options).unwrap();
1767        assert!(result.protocol.fragments.contains_key("index.html"));
1768        assert_eq!(result.css_files.len(), 1);
1769        assert!(result.css_files[0].1.contains("border"));
1770    }
1771
1772    #[test]
1773    fn test_build_with_scriptless_component_path_emits_dormant_template() {
1774        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1775        let ext_dir = TempDir::new().unwrap();
1776        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1777
1778        let mut options = default_options(app.path());
1779        options.plugin = Some(Plugin::WebUI);
1780        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1781
1782        let result = build(options).unwrap();
1783        assert!(result
1784            .component_templates
1785            .iter()
1786            .any(|template| template.tag_name == "ext-card"));
1787        let component = result
1788            .protocol
1789            .components
1790            .get("ext-card")
1791            .expect("external scriptless component metadata should be retained");
1792        assert!(component.hydration_keys.is_empty());
1793        assert_eq!(component.navigation_keys, ["title"]);
1794        assert!(component.template_json.contains(r#""th":1"#));
1795    }
1796
1797    #[test]
1798    fn test_build_with_scripted_component_path_emits_client_artifact() {
1799        let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1800        let ext_dir = TempDir::new().unwrap();
1801        fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1802        fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
1803
1804        let mut options = default_options(app.path());
1805        options.plugin = Some(Plugin::WebUI);
1806        options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1807
1808        let result = build(options).unwrap();
1809        let template = &result.protocol.components["ext-card"].template_json;
1810        assert!(!template.is_empty(), "template should be emitted");
1811    }
1812
1813    #[test]
1814    fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
1815        let app = create_app_dir(&[
1816            ("index.html", "<demo-card></demo-card>"),
1817            ("demo-card.html", "<p>{{name}}</p>"),
1818            ("demo-card.ts", "export {};"),
1819        ]);
1820        let manifest_json = projection::test_support::build_valid_manifest_json(
1821            app.path(),
1822            &[("demo-card.ts", "export {};")],
1823            &[("projection-bundle.js", "output")],
1824            &[(
1825                "demo-card",
1826                "demo-card.ts",
1827                &["projection-bundle.js"],
1828                &["name"],
1829                &["name"],
1830            )],
1831        );
1832        let manifest = projection::test_support::write_manifest(
1833            app.path(),
1834            "webui-projection.json",
1835            &manifest_json,
1836        );
1837        let mut options = default_options(app.path());
1838        // No plugin selected: default/FAST builds are incompatible with
1839        // projection manifests (`PROJ-B002`).
1840        options.projection_manifests = vec![manifest.into()];
1841        let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
1842        assert!(
1843            err.chain_message().contains("PROJ-B002"),
1844            "msg: {}",
1845            err.chain_message()
1846        );
1847    }
1848
1849    #[test]
1850    fn test_build_rejects_missing_scripted_component_coverage() {
1851        let app = create_app_dir(&[
1852            ("index.html", "<demo-card></demo-card>"),
1853            ("demo-card.html", "<p>{{name}}</p>"),
1854            ("demo-card.ts", "export {};"),
1855        ]);
1856        // Manifest is valid but never mentions `demo-card`, the only
1857        // compiled scripted component in this build.
1858        let manifest_json =
1859            projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1860        let manifest = projection::test_support::write_manifest(
1861            app.path(),
1862            "webui-projection.json",
1863            &manifest_json,
1864        );
1865        let mut options = default_options(app.path());
1866        options.plugin = Some(Plugin::WebUI);
1867        options.projection_manifests = vec![manifest.into()];
1868        let err = build(options).expect_err("missing coverage should fail the build");
1869        let message = err.chain_message();
1870        assert!(message.contains("PROJ-B001"), "msg: {message}");
1871        assert!(message.contains("demo-card"), "msg: {message}");
1872    }
1873
1874    #[test]
1875    fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
1876        let app = create_app_dir(&[
1877            ("index.html", "<demo-card></demo-card>"),
1878            ("demo-card.html", "<p>{{name}}</p>"),
1879            ("demo-card.ts", "export {};"),
1880            // `unused-card` is a scripted component discovered on disk but
1881            // never referenced from `index.html`, so it is not compiled
1882            // into the protocol and does not require manifest coverage.
1883            ("unused-card.html", "<p>{{title}}</p>"),
1884            ("unused-card.ts", "export {};"),
1885        ]);
1886        let manifest_json = projection::test_support::build_valid_manifest_json(
1887            app.path(),
1888            &[("demo-card.ts", "export {};")],
1889            &[("projection-bundle.js", "output")],
1890            &[(
1891                "demo-card",
1892                "demo-card.ts",
1893                &["projection-bundle.js"],
1894                &["name"],
1895                &["name"],
1896            )],
1897        );
1898        let manifest = projection::test_support::write_manifest(
1899            app.path(),
1900            "webui-projection.json",
1901            &manifest_json,
1902        );
1903        let mut options = default_options(app.path());
1904        options.plugin = Some(Plugin::WebUI);
1905        options.projection_manifests = vec![manifest.into()];
1906        let result = build(options).unwrap();
1907
1908        assert!(!result.protocol.fragments.contains_key("unused-card"));
1909        let component = result.protocol.components.get("demo-card").unwrap();
1910        assert_eq!(component.hydration_keys, vec!["name"]);
1911    }
1912
1913    #[test]
1914    fn test_build_merges_multiple_projection_manifest_fragments() {
1915        let app = create_app_dir(&[
1916            (
1917                "index.html",
1918                "<html><body><card-a></card-a><card-b></card-b></body></html>",
1919            ),
1920            ("card-a.html", "<p>{{alpha}}</p>"),
1921            ("card-a.ts", "export {};"),
1922            ("card-b.html", "<p>{{beta}}</p>"),
1923            ("card-b.ts", "export {};"),
1924        ]);
1925        let json_a = projection::test_support::build_valid_manifest_json(
1926            app.path(),
1927            &[("card-a.ts", "export {};")],
1928            &[("card-a.js", "output-a")],
1929            &[(
1930                "card-a",
1931                "card-a.ts",
1932                &["card-a.js"],
1933                &["alpha"],
1934                &["alpha"],
1935            )],
1936        );
1937        let manifest_a =
1938            projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
1939        let json_b = projection::test_support::build_valid_manifest_json(
1940            app.path(),
1941            &[("card-b.ts", "export {};")],
1942            &[("card-b.js", "output-b")],
1943            &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
1944        );
1945        let manifest_b =
1946            projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
1947
1948        let mut options = default_options(app.path());
1949        options.plugin = Some(Plugin::WebUI);
1950        options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
1951        let result = build(options).unwrap();
1952
1953        assert_eq!(
1954            result.protocol.initial_state_strategy,
1955            webui_protocol::InitialStateStrategy::Components as i32
1956        );
1957        let card_a = result.protocol.components.get("card-a").unwrap();
1958        assert_eq!(card_a.hydration_keys, vec!["alpha"]);
1959        let card_b = result.protocol.components.get("card-b").unwrap();
1960        assert_eq!(card_b.hydration_keys, vec!["beta"]);
1961    }
1962
1963    #[test]
1964    fn test_build_hello_world_example() {
1965        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1966        let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
1967
1968        let result = build(BuildOptions {
1969            app_dir,
1970            entry: "index.html".to_string(),
1971            ..BuildOptions::default()
1972        })
1973        .unwrap();
1974
1975        let index = &result.protocol.fragments["index.html"].fragments;
1976        assert!(index.iter().any(
1977            |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
1978        ));
1979        assert!(index
1980            .iter()
1981            .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1982    }
1983
1984    // ── Security tests ───────────────────────────────────────────────
1985
1986    #[test]
1987    fn test_css_filename_sanitizes_path_separators() {
1988        // Even if a component tag somehow contains path separators,
1989        // the filename should be sanitized to prevent directory traversal.
1990        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1991        let result = build(default_options(app.path())).unwrap();
1992
1993        // Verify all CSS filenames are plain file names (no path separators)
1994        for (filename, _) in &result.css_files {
1995            assert!(
1996                !filename.contains('/') && !filename.contains('\\'),
1997                "CSS filename contains path separator: {filename}"
1998            );
1999        }
2000    }
2001
2002    #[test]
2003    fn test_build_to_disk_css_stays_in_output_dir() {
2004        let app = create_app_dir(&[
2005            ("index.html", "<my-card>Hello</my-card>"),
2006            ("my-card.html", "<div><slot></slot></div>"),
2007            ("my-card.css", ".card { color: red; }"),
2008        ]);
2009        let out = TempDir::new().unwrap();
2010
2011        build_to_disk(default_options(app.path()), out.path()).unwrap();
2012
2013        // Verify CSS file is written inside out_dir
2014        let css_path = out.path().join("my-card.css");
2015        assert!(css_path.exists());
2016        let canonical = css_path.canonicalize().unwrap();
2017        let out_canonical = out.path().canonicalize().unwrap();
2018        assert!(
2019            canonical.starts_with(&out_canonical),
2020            "CSS file escaped output directory"
2021        );
2022    }
2023
2024    // ── Performance / edge case tests ────────────────────────────────
2025
2026    #[test]
2027    fn test_build_empty_html() {
2028        let app = create_app_dir(&[("index.html", "")]);
2029        let result = build(default_options(app.path())).unwrap();
2030
2031        assert!(result.protocol.fragments.contains_key("index.html"));
2032        assert_eq!(result.stats.fragment_count, 0);
2033        assert!(result.stats.protocol_size_bytes > 0);
2034    }
2035
2036    #[test]
2037    fn test_build_large_fragment_count() {
2038        // Verify stats are accurate with many fragments
2039        let mut html = String::with_capacity(2000);
2040        for i in 0..50 {
2041            html.push_str(&format!("<p>Item {i}</p>\n"));
2042        }
2043        let app = create_app_dir(&[("index.html", &html)]);
2044        let result = build(default_options(app.path())).unwrap();
2045
2046        assert!(result.stats.fragment_count > 0);
2047        assert_eq!(result.stats.css_file_count, 0);
2048    }
2049
2050    #[test]
2051    fn test_build_with_fast_plugin() {
2052        let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
2053        let mut options = default_options(app.path());
2054        options.plugin = Some(Plugin::FastV3);
2055
2056        let result = build(options).unwrap();
2057        assert!(result.protocol.fragments.contains_key("index.html"));
2058        assert_eq!(
2059            result.protocol.initial_state_strategy,
2060            webui_protocol::InitialStateStrategy::Full as i32
2061        );
2062    }
2063
2064    #[test]
2065    fn test_build_to_disk_inline_mode_no_css_files() {
2066        let app = create_app_dir(&[
2067            ("index.html", "<my-card>Hello</my-card>"),
2068            ("my-card.html", "<div><slot></slot></div>"),
2069            ("my-card.css", ".card { color: red; }"),
2070        ]);
2071        let out = TempDir::new().unwrap();
2072        let mut options = default_options(app.path());
2073        options.css = CssStrategy::Style;
2074
2075        let stats = build_to_disk(options, out.path()).unwrap();
2076
2077        assert!(out.path().join("protocol.bin").exists());
2078        assert!(!out.path().join("my-card.css").exists());
2079        assert_eq!(stats.css_file_count, 0);
2080    }
2081
2082    #[test]
2083    fn test_build_stats_duration_is_nonzero() {
2084        let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
2085        let result = build(default_options(app.path())).unwrap();
2086
2087        assert!(!result.stats.duration.is_zero());
2088    }
2089
2090    #[test]
2091    fn test_build_multiple_components_css() {
2092        let app = create_app_dir(&[
2093            ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2094            ("card-a.html", "<div><slot></slot></div>"),
2095            ("card-a.css", ".a { color: red; }"),
2096            ("card-b.html", "<span><slot></slot></span>"),
2097            ("card-b.css", ".b { color: blue; }"),
2098        ]);
2099        let result = build(default_options(app.path())).unwrap();
2100
2101        assert_eq!(result.css_files.len(), 2);
2102        assert_eq!(result.stats.css_file_count, 2);
2103        let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
2104        assert!(filenames.contains(&"card-a.css"));
2105        assert!(filenames.contains(&"card-b.css"));
2106    }
2107
2108    #[test]
2109    fn test_build_unused_component_css_not_emitted() {
2110        // card-b is registered but not referenced in index.html
2111        let app = create_app_dir(&[
2112            ("index.html", "<card-a>A</card-a>"),
2113            ("card-a.html", "<div><slot></slot></div>"),
2114            ("card-a.css", ".a { color: red; }"),
2115            ("card-b.html", "<span><slot></slot></span>"),
2116            ("card-b.css", ".b { color: blue; }"),
2117        ]);
2118        let result = build(default_options(app.path())).unwrap();
2119
2120        assert_eq!(result.css_files.len(), 1);
2121        assert_eq!(result.css_files[0].0, "card-a.css");
2122    }
2123
2124    #[test]
2125    fn test_inspect_roundtrip_preserves_content() {
2126        let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
2127        let result = build(default_options(app.path())).unwrap();
2128
2129        let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
2130        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2131
2132        let fragments = &parsed["fragments"]["index.html"]["fragments"];
2133        assert!(fragments.is_array());
2134        assert!(!fragments.as_array().unwrap().is_empty());
2135    }
2136
2137    #[test]
2138    fn test_build_nonexistent_app_dir() {
2139        let options = BuildOptions {
2140            app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2141            ..BuildOptions::default()
2142        };
2143        let result = build(options);
2144        assert!(result.is_err());
2145    }
2146
2147    #[test]
2148    fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
2149        let app = create_app_dir(&[
2150            ("index.html", "<my-card></my-card>"),
2151            ("my-card.html", "<div>Card</div>"),
2152            (
2153                "my-card.css",
2154                ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
2155            ),
2156        ]);
2157        let mut options = default_options(app.path());
2158        options.theme = Some(TokenFile {
2159            themes: HashMap::from([(
2160                "light".to_string(),
2161                HashMap::from([
2162                    ("token-a".to_string(), "red".to_string()),
2163                    ("token-b".to_string(), "blue".to_string()),
2164                    ("token-c".to_string(), "green".to_string()),
2165                ]),
2166            )]),
2167        });
2168
2169        let result = build(options).expect("all fallback tokens are defined");
2170        assert!(result.protocol.tokens.contains(&"token-a".to_string()));
2171        assert!(result.protocol.tokens.contains(&"token-b".to_string()));
2172        assert!(result.protocol.tokens.contains(&"token-c".to_string()));
2173    }
2174
2175    #[test]
2176    fn test_build_allows_literal_fallback_token_absent_from_theme() {
2177        let app = create_app_dir(&[
2178            ("index.html", "<my-card></my-card>"),
2179            ("my-card.html", "<div>Card</div>"),
2180            ("my-card.css", ":host { color: var(--brand, #000); }"),
2181        ]);
2182        let mut options = default_options(app.path());
2183        options.theme = Some(TokenFile {
2184            themes: HashMap::from([("light".to_string(), HashMap::new())]),
2185        });
2186
2187        // The CSS literal fallback (`#000`) means `--brand` is not required from
2188        // the theme, so the build must succeed — but the token is still hoisted
2189        // so the runtime resolves it when a theme provides it.
2190        let result = build(options).expect("literal fallback should not fail the build");
2191        assert!(result.protocol.tokens.contains(&"brand".to_string()));
2192        // `--brand` is absent from every theme and only used with a literal
2193        // fallback, so it is surfaced as a non-fatal typo advisory.
2194        assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
2195        assert!(result.warnings[0].body().contains("--brand"));
2196    }
2197
2198    #[test]
2199    fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
2200        // `--foo-bar` is defined in the entry's inline <style> after a signal
2201        // comment and consumed inside a component. Custom properties inherit
2202        // through Shadow DOM, so the ancestor definition must satisfy the
2203        // descendant and the token must not be required from the theme.
2204        let app = create_app_dir(&[
2205            (
2206                "index.html",
2207                "<html><head><style>:root {\n  /*{{{tokens.light}}}*/\n  --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
2208            ),
2209            ("my-card.html", "<div>Card</div>"),
2210            ("my-card.css", ":host { padding: var(--foo-bar); }"),
2211        ]);
2212        let mut options = default_options(app.path());
2213        options.theme = Some(TokenFile {
2214            themes: HashMap::from([("light".to_string(), HashMap::new())]),
2215        });
2216
2217        let result = build(options)
2218            .expect("ancestor inline-style definition (after a comment) should satisfy the child");
2219        assert!(
2220            !result.protocol.tokens.contains(&"foo-bar".to_string()),
2221            "ancestor-defined token should not be hoisted: {:?}",
2222            result.protocol.tokens
2223        );
2224    }
2225
2226    #[test]
2227    fn test_build_no_warning_when_literal_fallback_token_is_themed() {
2228        let app = create_app_dir(&[
2229            ("index.html", "<my-card></my-card>"),
2230            ("my-card.html", "<div>Card</div>"),
2231            ("my-card.css", ":host { color: var(--brand, #000); }"),
2232        ]);
2233        let mut options = default_options(app.path());
2234        options.theme = Some(TokenFile {
2235            themes: HashMap::from([(
2236                "light".to_string(),
2237                HashMap::from([("brand".to_string(), "#123456".to_string())]),
2238            )]),
2239        });
2240
2241        let result = build(options).expect("themed literal-fallback token builds");
2242        assert!(
2243            result.warnings.is_empty(),
2244            "a themed token must not warn: {:?}",
2245            result.warnings
2246        );
2247    }
2248
2249    #[test]
2250    fn test_build_without_theme_produces_no_warnings() {
2251        let app = create_app_dir(&[
2252            ("index.html", "<my-card></my-card>"),
2253            ("my-card.html", "<div>Card</div>"),
2254            ("my-card.css", ":host { color: var(--brand, #000); }"),
2255        ]);
2256        let result = build(default_options(app.path())).unwrap();
2257        assert!(result.warnings.is_empty());
2258    }
2259
2260    #[test]
2261    fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
2262        let app = create_app_dir(&[
2263            ("index.html", "<component-a></component-a>"),
2264            ("component-a.html", "<component-b></component-b>"),
2265            ("component-a.css", ":host { --brand-color: red; }"),
2266            ("component-b.html", "<div>Child</div>"),
2267            ("component-b.css", ":host { color: var(--brand-color); }"),
2268        ]);
2269        let mut options = default_options(app.path());
2270        options.theme = Some(TokenFile {
2271            themes: HashMap::from([("light".to_string(), HashMap::new())]),
2272        });
2273
2274        let result =
2275            build(options).expect("ancestor component custom property should satisfy child");
2276        assert!(
2277            !result.protocol.tokens.contains(&"brand-color".to_string()),
2278            "ancestor-defined token should not be exposed: {:?}",
2279            result.protocol.tokens
2280        );
2281    }
2282
2283    #[test]
2284    fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
2285        let app = create_app_dir(&[
2286            (
2287                "index.html",
2288                "<component-a></component-a><component-b></component-b>",
2289            ),
2290            ("component-a.html", "<div>A</div>"),
2291            ("component-a.css", ":host { --brand-color: red; }"),
2292            ("component-b.html", "<div>B</div>"),
2293            ("component-b.css", ":host { color: var(--brand-color); }"),
2294        ]);
2295        let mut options = default_options(app.path());
2296        options.theme = Some(TokenFile {
2297            themes: HashMap::from([("light".to_string(), HashMap::new())]),
2298        });
2299
2300        let Err(err) = build(options) else {
2301            panic!("sibling custom property must not satisfy component-b");
2302        };
2303        let message = err.chain_message();
2304        assert!(message.contains("missing theme token"), "msg: {message}");
2305        assert!(message.contains("missing-theme-token"), "msg: {message}");
2306        assert!(message.contains("--brand-color"), "msg: {message}");
2307    }
2308
2309    #[test]
2310    fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
2311        let app = create_app_dir(&[
2312            ("index.html", "<my-card></my-card>"),
2313            ("my-card.html", "<div>Card</div>"),
2314            (
2315                "my-card.css",
2316                ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
2317            ),
2318        ]);
2319        let mut options = default_options(app.path());
2320        options.theme = Some(TokenFile {
2321            themes: HashMap::from([(
2322                "light".to_string(),
2323                HashMap::from([("token-b".to_string(), "green".to_string())]),
2324            )]),
2325        });
2326
2327        let Err(err) = build(options) else {
2328            panic!("missing --token-c in the theme must fail the build");
2329        };
2330        let message = err.chain_message();
2331        assert!(message.contains("missing-theme-token"), "msg: {message}");
2332        assert!(message.contains("--token-c"), "msg: {message}");
2333        // The error demands the missing `--token-c`, never the locally-defined
2334        // `--token-a` (which only appears in the source snippet as context).
2335        assert!(!message.contains("add --token-a"), "msg: {message}");
2336    }
2337
2338    #[test]
2339    fn test_build_to_disk_returns_accurate_stats() {
2340        let app = create_app_dir(&[
2341            ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
2342            ("my-card.html", "<div><slot></slot></div>"),
2343            ("my-card.css", ".card { color: red; }"),
2344        ]);
2345        let out = TempDir::new().unwrap();
2346
2347        let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
2348
2349        assert!(stats.fragment_count > 0);
2350        assert_eq!(stats.css_file_count, 1);
2351        assert!(stats.component_count > 0);
2352        assert!(stats.protocol_size_bytes > 0);
2353        assert!(!stats.duration.is_zero());
2354    }
2355}