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