Skip to main content

webui_discovery/plugin/fast/
mod.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! FAST manifest naming and converted template/style layouts.
5
6use super::DiscoveryPlugin;
7use crate::catalog::PackageCatalog;
8use crate::npm::{
9    bare_module_package_name, package_component_declarations, package_export_path,
10    package_export_path_from_metadata, package_has_authored_script, read_optional_file,
11    read_required_file, resolve_bare_module_specifier, resolve_self_module_specifier,
12    validate_package_asset_path, ComponentDeclaration, PackageContext,
13};
14use crate::{
15    has_sibling_script, ComponentFile, ComponentFileSource, DiscoveredComponent, PreparedPackage,
16};
17use anyhow::{bail, Context, Result};
18use std::collections::HashSet;
19use std::path::{Component, Path, PathBuf};
20use std::sync::Arc;
21use walkdir::WalkDir;
22
23/// Discovery for FAST generated component layouts.
24#[derive(Debug, Default, Clone, Copy)]
25pub struct FastDiscoveryPlugin;
26
27impl FastDiscoveryPlugin {
28    /// Create FAST discovery.
29    #[must_use]
30    pub const fn new() -> Self {
31        Self
32    }
33}
34
35impl DiscoveryPlugin for FastDiscoveryPlugin {
36    fn cache_namespace(&self) -> &'static str {
37        "fast-prepared-v1"
38    }
39
40    fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
41        discover_local_templates(root, fast_local_tag)
42    }
43
44    fn supports_package(&self, package: PackageContext<'_>) -> Result<bool> {
45        if package.manifest.get("customElements").is_some() {
46            return Ok(true);
47        }
48        PackageCatalog::new(package)?.has_templates(ordinary_html)
49    }
50
51    fn prepare_package(&self, package: PackageContext<'_>) -> Result<PreparedPackage> {
52        let declarations = declarations(package)?;
53        let catalog = PackageCatalog::new(package)?;
54        let mut components = {
55            let names: HashSet<_> = declarations
56                .iter()
57                .map(|item| item.tag_name.as_str())
58                .collect();
59            catalog.prepare(|path| fallback_html(path, &names))?
60        };
61        if declarations.is_empty() {
62            if components.is_empty() && !catalog.is_disabled() {
63                bail!(
64                    "No components found in package '{}'. Declare FAST components through \
65                     customElements, provide <component-name>.html files under components/, \
66                     or declare webui.components in package.json.",
67                    package.name
68                );
69            }
70            return Ok(PreparedPackage::Files(components));
71        }
72
73        let assets = exported_assets(package, declarations.len())?;
74        components.reserve(declarations.len());
75        let source_is_client_owned = package_has_authored_script(package.manifest);
76        let package_root: Arc<Path> = package.root.into();
77        let mut seen_templates = HashSet::with_capacity(declarations.len());
78        for declaration in declarations {
79            let uses_package_template = assets.template.is_some();
80            let module = resolve_declaration_module(package, &declaration, !uses_package_template)?;
81            let module_path = package_path(&module.root, &module.relative_path)
82                .context("FAST component has an invalid CEM module path")?;
83            validate_module_asset(&module, &module_path, "FAST component module")?;
84            let template_path = if let Some(path) = &assets.template {
85                path.clone()
86            } else {
87                inferred_template(package, &declaration, &module)?
88            };
89            if !seen_templates.insert(template_path.clone()) {
90                bail!(
91                    "FAST template {} maps to multiple component declarations",
92                    template_path.display()
93                );
94            }
95            let module_root: Arc<Path> = module.root.as_path().into();
96            let template_root = if uses_package_template {
97                &package_root
98            } else {
99                &module_root
100            };
101            let module_styles = if uses_package_template {
102                None
103            } else {
104                module.exported_styles.as_ref()
105            };
106            let css = match (&assets.styles, module_styles) {
107                (Some(path), _) => Some(ComponentFile::new(Arc::clone(&package_root), path)?),
108                (None, Some(path)) => Some(ComponentFile::new(Arc::clone(&module_root), path)?),
109                (None, None) => resolve_package_styles(template_root, &template_path)?
110                    .map(|path| ComponentFile::new(Arc::clone(template_root), &path))
111                    .transpose()?,
112            };
113            components.push(ComponentFileSource {
114                tag_name: declaration.tag_name,
115                html: ComponentFile::new(Arc::clone(template_root), &template_path)?,
116                css,
117                is_client_owned: source_is_client_owned || module.is_client_owned,
118            });
119        }
120        Ok(PreparedPackage::Files(components))
121    }
122}
123
124fn declarations(package: PackageContext<'_>) -> Result<Vec<ComponentDeclaration>> {
125    if package.manifest.get("customElements").is_some() {
126        package_component_declarations(package)
127    } else {
128        Ok(Vec::new())
129    }
130}
131
132fn ordinary_html(path: &Path) -> bool {
133    path.file_name()
134        .and_then(|name| name.to_str())
135        .is_some_and(|name| {
136            !name.ends_with(".template.html") && !name.ends_with(".template-webui.html")
137        })
138}
139
140fn fallback_html(path: &Path, names: &HashSet<&str>) -> bool {
141    ordinary_html(path)
142        && crate::catalog::template_tag(path).is_some_and(|name| !names.contains(name))
143}
144
145struct ExportedAssets {
146    template: Option<PathBuf>,
147    styles: Option<PathBuf>,
148}
149
150fn exported_assets(package: PackageContext<'_>, declarations: usize) -> Result<ExportedAssets> {
151    let template = package_export_path(package, "./template-webui.html")?;
152    if template.as_ref().is_some_and(|path| {
153        !path
154            .file_name()
155            .and_then(|name| name.to_str())
156            .is_some_and(|name| name.ends_with(".template-webui.html"))
157    }) {
158        bail!(
159            "Package '{}' must export a .template-webui.html file for FAST discovery.",
160            package.name
161        );
162    }
163    if template.is_some() && declarations != 1 {
164        bail!(
165            "Package '{}' exports one './template-webui.html' but declares {declarations} components. \
166             Use one component declaration or per-module .template-webui.html files.",
167            package.name
168        );
169    }
170    let styles = if declarations == 1 {
171        package_export_path(package, "./styles.css")?
172    } else {
173        None
174    };
175    Ok(ExportedAssets { template, styles })
176}
177
178fn inferred_template(
179    package: PackageContext<'_>,
180    declaration: &ComponentDeclaration,
181    module: &ResolvedCemModule,
182) -> Result<PathBuf> {
183    if let Some(template) = &module.exported_template {
184        return Ok(template.clone());
185    }
186    let name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
187    let template =
188        resolve_fast_template(&module.root, &module.relative_path, name).with_context(|| {
189            format!(
190                "Failed to locate FAST template for <{}> in package '{}'",
191                declaration.tag_name, package.name
192            )
193        })?;
194    validate_module_asset(module, &template, "FAST component template")?;
195    Ok(template)
196}
197
198fn validate_module_asset(module: &ResolvedCemModule, path: &Path, kind: &str) -> Result<()> {
199    validate_package_asset_path(&module.root, path, kind)
200        .with_context(|| format!("{kind} resolved outside package: {}", path.display()))?;
201    Ok(())
202}
203
204struct ResolvedCemModule {
205    root: PathBuf,
206    relative_path: PathBuf,
207    exported_template: Option<PathBuf>,
208    exported_styles: Option<PathBuf>,
209    is_client_owned: bool,
210}
211
212fn resolve_declaration_module(
213    package: PackageContext<'_>,
214    declaration: &ComponentDeclaration,
215    resolve_assets: bool,
216) -> Result<ResolvedCemModule> {
217    resolve_declaration_module_inner(package, declaration, resolve_assets).with_context(|| {
218        format!(
219            "Failed to resolve FAST component <{}> declared by package '{}'",
220            declaration.tag_name, package.name
221        )
222    })
223}
224
225fn resolve_declaration_module_inner(
226    package: PackageContext<'_>,
227    declaration: &ComponentDeclaration,
228    resolve_assets: bool,
229) -> Result<ResolvedCemModule> {
230    let specifier = declaration.module_specifier.as_deref().with_context(|| {
231        format!(
232            "FAST component <{}> in package '{}' has no CEM module path",
233            declaration.tag_name, package.name
234        )
235    })?;
236    let relative_path = Path::new(specifier);
237    let name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
238    let explicitly_relative = specifier.starts_with("./") || specifier.starts_with("../");
239    let local_module = package_path(package.root, relative_path);
240    let local_candidates = fast_template_candidates(package.root, relative_path, name);
241    let has_local_module = local_module.as_ref().is_some_and(|path| path.is_file());
242    let has_local_template = local_candidates.iter().any(|path| path.is_file());
243    if explicitly_relative
244        || (!specifier.starts_with('@') && (has_local_module || has_local_template))
245    {
246        return Ok(ResolvedCemModule {
247            root: package.root.to_path_buf(),
248            relative_path: relative_path.to_path_buf(),
249            exported_template: None,
250            exported_styles: None,
251            is_client_owned: false,
252        });
253    }
254    let bare_package = bare_module_package_name(specifier)?;
255    let manifest = package.manifest;
256    let self_name = manifest.get("name").and_then(serde_json::Value::as_str);
257    if let Some(self_name) = self_name {
258        if bare_package == Some(self_name) {
259            return Ok(ResolvedCemModule {
260                root: package.root.to_path_buf(),
261                relative_path: resolve_self_module_specifier(
262                    self_name,
263                    package.root,
264                    specifier,
265                    manifest,
266                )?,
267                exported_template: None,
268                exported_styles: None,
269                is_client_owned: false,
270            });
271        }
272    }
273    let can_resolve_bare = specifier.starts_with('@')
274        || match bare_package {
275            Some(name) => package_has_dependency(manifest, name),
276            None => false,
277        };
278    if can_resolve_bare {
279        let resolved = resolve_bare_module_specifier(specifier, package.root)?.with_context(|| {
280            format!(
281                "Package '{}' declares bare CEM module '{specifier}', but its package could not be resolved",
282                package.name
283            )
284        })?;
285        let exported_template = if resolve_assets {
286            package_export_path_from_metadata(
287                &resolved.name,
288                &resolved.root,
289                &resolved.ordered_manifest,
290                "./template-webui.html",
291            )?
292        } else {
293            None
294        };
295        if exported_template.as_ref().is_some_and(|path| {
296            !path
297                .file_name()
298                .and_then(|name| name.to_str())
299                .is_some_and(|name| name.ends_with(".template-webui.html"))
300        }) {
301            bail!(
302                "Package '{}' must export a .template-webui.html file for FAST discovery.",
303                resolved.name
304            );
305        }
306        let exported_styles = if resolve_assets {
307            package_export_path_from_metadata(
308                &resolved.name,
309                &resolved.root,
310                &resolved.ordered_manifest,
311                "./styles.css",
312            )?
313        } else {
314            None
315        };
316        let is_client_owned = package_has_authored_script(&resolved.manifest)
317            || module_path_is_script(&resolved.relative_path);
318        return Ok(ResolvedCemModule {
319            root: resolved.root,
320            relative_path: resolved.relative_path,
321            exported_template,
322            exported_styles,
323            is_client_owned,
324        });
325    }
326    Ok(ResolvedCemModule {
327        root: package.root.to_path_buf(),
328        relative_path: relative_path.to_path_buf(),
329        exported_template: None,
330        exported_styles: None,
331        is_client_owned: false,
332    })
333}
334
335fn package_has_dependency(manifest: &serde_json::Value, name: &str) -> bool {
336    ["dependencies", "peerDependencies", "optionalDependencies"]
337        .into_iter()
338        .any(|field| {
339            manifest
340                .get(field)
341                .and_then(serde_json::Value::as_object)
342                .is_some_and(|dependencies| dependencies.contains_key(name))
343        })
344}
345
346fn module_path_is_script(path: &Path) -> bool {
347    path.extension()
348        .and_then(|extension| extension.to_str())
349        .is_some_and(|extension| matches!(extension, "js" | "mjs" | "cjs" | "ts"))
350}
351
352fn discover_local_templates(
353    root: &Path,
354    tag_name_for_path: fn(&Path) -> Option<&str>,
355) -> Result<Vec<DiscoveredComponent>> {
356    let source = root.display().to_string();
357    let mut components = Vec::new();
358    let mut sibling = PathBuf::new();
359    for entry in WalkDir::new(root).sort_by_file_name() {
360        let entry = entry.with_context(|| format!("Failed to scan {}", root.display()))?;
361        let path = entry.path();
362        if !path.is_file()
363            || !path
364                .extension()
365                .is_some_and(|extension| extension == "html")
366        {
367            continue;
368        }
369        let Some(tag_name) = tag_name_for_path(path) else {
370            continue;
371        };
372        let html_content = read_required_file(path, "component template")?;
373        sibling.clear();
374        sibling.push(path);
375        let css_content =
376            read_optional_file(resolve_local_styles(path).as_deref(), "component styles")?;
377        components.push(DiscoveredComponent {
378            tag_name: tag_name.to_string(),
379            html_content,
380            css_content,
381            is_client_owned: has_sibling_script(&mut sibling, None)?,
382            source: source.clone(),
383        });
384    }
385    Ok(components)
386}
387
388fn fast_local_tag(path: &Path) -> Option<&str> {
389    let file_name = path.file_name()?.to_str()?;
390    if file_name.ends_with(".template.html") {
391        return None;
392    }
393    file_name
394        .strip_suffix(".template-webui.html")
395        .or_else(|| crate::catalog::template_tag(path))
396}
397
398fn resolve_local_styles(template_path: &Path) -> Option<PathBuf> {
399    let standard = template_path.with_extension("css");
400    if standard.is_file() {
401        return Some(standard);
402    }
403    resolve_fast_styles(template_path)
404}
405
406fn resolve_fast_styles(template_path: &Path) -> Option<PathBuf> {
407    fast_style_candidates(template_path)
408        .into_iter()
409        .find(|candidate| candidate.is_file())
410}
411
412fn resolve_package_styles(root: &Path, template: &Path) -> Result<Option<PathBuf>> {
413    for path in fast_style_candidates(template) {
414        validate_package_asset_path(root, &path, "FAST component styles")?;
415        if path.is_file() {
416            return Ok(Some(path));
417        }
418    }
419    Ok(None)
420}
421
422fn fast_style_candidates(template_path: &Path) -> Vec<PathBuf> {
423    let Some(file_name) = template_path.file_name().and_then(|name| name.to_str()) else {
424        return Vec::new();
425    };
426    let Some(prefix) = file_name.strip_suffix(".template-webui.html") else {
427        return Vec::new();
428    };
429    let Some(parent) = template_path.parent() else {
430        return Vec::new();
431    };
432    let mut candidates = Vec::with_capacity(2);
433    for suffix in [".styles.css", ".css"] {
434        candidates.push(parent.join(format!("{prefix}{suffix}")));
435    }
436    candidates
437}
438
439fn fast_template_candidates(
440    root: &Path,
441    module_path: &Path,
442    declaration_name: &str,
443) -> Vec<PathBuf> {
444    let Some(module) = package_path(root, module_path) else {
445        return Vec::new();
446    };
447    let parent = module.parent().unwrap_or(root);
448    let module_stem = module.file_stem().and_then(|stem| stem.to_str());
449    let declaration_stem = to_kebab_case(declaration_name);
450    let mut candidates = Vec::with_capacity(5);
451    for stem in module_stem
452        .into_iter()
453        .chain(std::iter::once(declaration_stem.as_str()))
454    {
455        push_template_candidate(&mut candidates, parent, stem);
456    }
457
458    // Some generated manifests expose a virtual public module path while the
459    // package stores the implementation under a compact or base class noun.
460    if !module.is_file() {
461        let component_root = if parent == root {
462            root
463        } else {
464            parent
465                .parent()
466                .filter(|candidate| candidate.starts_with(root))
467                .unwrap_or(root)
468        };
469        push_nested_template_candidate(&mut candidates, component_root, declaration_stem.as_str());
470        let compact_stem = declaration_stem.replace('-', "");
471        push_nested_template_candidate(&mut candidates, component_root, &compact_stem);
472        if let Some((_, suffix)) = declaration_stem.rsplit_once('-') {
473            push_nested_template_candidate(&mut candidates, component_root, suffix);
474        }
475    }
476    if !candidates.iter().any(|candidate| candidate.is_file()) {
477        for directory in parent
478            .ancestors()
479            .skip(1)
480            .take_while(|path| path.starts_with(root))
481        {
482            for stem in module_stem
483                .into_iter()
484                .chain(std::iter::once(declaration_stem.as_str()))
485            {
486                push_template_candidate(&mut candidates, directory, stem);
487            }
488        }
489    }
490    candidates
491}
492
493fn package_path(root: &Path, relative: &Path) -> Option<PathBuf> {
494    if relative.is_absolute()
495        || relative.components().any(|component| {
496            matches!(
497                component,
498                Component::Prefix(_) | Component::RootDir | Component::ParentDir
499            )
500        })
501    {
502        return None;
503    }
504    Some(root.join(relative))
505}
506
507fn push_template_candidate(candidates: &mut Vec<PathBuf>, parent: &Path, stem: &str) {
508    let candidate = parent.join(format!("{stem}.template-webui.html"));
509    if !candidates.contains(&candidate) {
510        candidates.push(candidate);
511    }
512}
513
514fn push_nested_template_candidate(candidates: &mut Vec<PathBuf>, root: &Path, stem: &str) {
515    push_template_candidate(candidates, &root.join(stem), stem);
516}
517
518fn resolve_fast_template(
519    root: &Path,
520    module_path: &Path,
521    declaration_name: &str,
522) -> Result<PathBuf> {
523    for candidate in fast_template_candidates(root, module_path, declaration_name) {
524        validate_package_asset_path(root, &candidate, "FAST component template")?;
525        if candidate.is_file() {
526            return Ok(candidate);
527        }
528    }
529    bail!("no matching <component>.template-webui.html file found; raw .template.html assets are not used")
530}
531
532fn to_kebab_case(name: &str) -> String {
533    let mut result = String::with_capacity(name.len() + 4);
534    for (index, character) in name.chars().enumerate() {
535        if character.is_ascii_uppercase() {
536            if index != 0 {
537                result.push('-');
538            }
539            result.push(character.to_ascii_lowercase());
540        } else {
541            result.push(character);
542        }
543    }
544    result
545}
546
547#[cfg(test)]
548mod tests;