Skip to main content

webui_discovery/
plugin.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Component discovery plugin contracts and built-in layouts.
5
6use crate::npm::{
7    package_component_declarations, read_optional_file, read_required_file, resolve_webui_assets,
8    PackageContext,
9};
10use crate::{has_sibling_script, DiscoveredComponent};
11use anyhow::{bail, Context, Result};
12use std::collections::HashSet;
13use std::path::{Component, Path, PathBuf};
14use walkdir::WalkDir;
15
16// Module + five templates + two style candidates per template.
17const FAST_CACHE_FILES_PER_COMPONENT: usize = 16;
18
19/// Maps a resolved local or npm package layout to WebUI component registrations.
20pub trait DiscoveryPlugin {
21    /// Stable cache namespace for this discovery layout.
22    fn cache_namespace(&self) -> &'static str;
23
24    /// Discover components below a local source root.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error when a claimed component source cannot be read.
29    fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>>;
30
31    /// Return every package file whose contents or existence affects discovery.
32    ///
33    /// Paths must be deterministic. Missing optional candidates should still be
34    /// included so creating one invalidates a prior cache entry.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error when package metadata needed to identify dependencies
39    /// is invalid.
40    fn package_cache_files(&self, package: PackageContext<'_>) -> Result<Vec<PathBuf>>;
41
42    /// Discover components in a validated npm package.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error when required package metadata or component sources are
47    /// missing or invalid.
48    fn discover_package(&self, package: PackageContext<'_>) -> Result<Vec<DiscoveredComponent>>;
49}
50
51/// Discovery for WebUI's native component package layout.
52#[derive(Debug, Default, Clone, Copy)]
53pub struct WebUIDiscoveryPlugin;
54
55impl WebUIDiscoveryPlugin {
56    /// Create WebUI native discovery.
57    #[must_use]
58    pub const fn new() -> Self {
59        Self
60    }
61}
62
63impl DiscoveryPlugin for WebUIDiscoveryPlugin {
64    fn cache_namespace(&self) -> &'static str {
65        "webui"
66    }
67
68    fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
69        discover_local_templates(root, webui_local_tag)
70    }
71
72    fn package_cache_files(&self, package: PackageContext<'_>) -> Result<Vec<PathBuf>> {
73        let assets = resolve_webui_assets(package)?;
74        let mut files = Vec::with_capacity(3);
75        files.push(assets.manifest_path);
76        files.push(assets.template_path);
77        if let Some(styles) = assets.styles_path {
78            files.push(styles);
79        }
80        Ok(files)
81    }
82
83    fn discover_package(&self, package: PackageContext<'_>) -> Result<Vec<DiscoveredComponent>> {
84        let assets = resolve_webui_assets(package)?;
85        let html_content = read_required_file(&assets.template_path, "component template")?;
86        let css_content = read_optional_file(assets.styles_path.as_deref(), "component styles")?;
87        let tag_names = package_component_declarations(package)?
88            .into_iter()
89            .map(|declaration| declaration.tag_name)
90            .collect::<Vec<_>>();
91        if tag_names.is_empty() {
92            bail!(
93                "No component tag names found in custom elements manifest: {}",
94                assets.manifest_path.display()
95            );
96        }
97
98        Ok(tag_names
99            .into_iter()
100            .map(|tag_name| DiscoveredComponent {
101                tag_name,
102                html_content: html_content.clone(),
103                css_content: css_content.clone(),
104                is_client_owned: package.is_client_owned,
105                source: package.name.to_string(),
106            })
107            .collect())
108    }
109}
110
111/// Discovery for FAST generated component layouts.
112#[derive(Debug, Default, Clone, Copy)]
113pub struct FastDiscoveryPlugin;
114
115impl FastDiscoveryPlugin {
116    /// Create FAST discovery.
117    #[must_use]
118    pub const fn new() -> Self {
119        Self
120    }
121}
122
123impl DiscoveryPlugin for FastDiscoveryPlugin {
124    fn cache_namespace(&self) -> &'static str {
125        "fast"
126    }
127
128    fn discover_local(&self, root: &Path) -> Result<Vec<DiscoveredComponent>> {
129        discover_local_templates(root, fast_local_tag)
130    }
131
132    fn package_cache_files(&self, package: PackageContext<'_>) -> Result<Vec<PathBuf>> {
133        let declarations = package_component_declarations(package)?;
134        let mut files = Vec::with_capacity(1 + declarations.len() * FAST_CACHE_FILES_PER_COMPONENT);
135        files.push(crate::npm::custom_elements_manifest_path(package)?);
136        for declaration in declarations {
137            let module_path = declaration.module_path.as_deref().with_context(|| {
138                format!(
139                    "FAST component <{}> in package '{}' has no CEM module path",
140                    declaration.tag_name, package.name
141                )
142            })?;
143            files.push(package_path(package.root, module_path).with_context(|| {
144                format!(
145                    "FAST component <{}> in package '{}' has an invalid CEM module path",
146                    declaration.tag_name, package.name
147                )
148            })?);
149            let declaration_name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
150            for candidate in fast_template_candidates(package.root, module_path, declaration_name) {
151                files.push(candidate.clone());
152                files.extend(fast_style_candidates(&candidate));
153            }
154        }
155        Ok(files)
156    }
157
158    fn discover_package(&self, package: PackageContext<'_>) -> Result<Vec<DiscoveredComponent>> {
159        let declarations = package_component_declarations(package)?;
160        if declarations.is_empty() {
161            bail!(
162                "No component declarations found in package '{}'",
163                package.name
164            );
165        }
166
167        let mut components = Vec::with_capacity(declarations.len());
168        let mut seen_templates = HashSet::with_capacity(declarations.len());
169        for declaration in declarations {
170            let module_path = declaration.module_path.as_deref().with_context(|| {
171                format!(
172                    "FAST component <{}> in package '{}' has no CEM module path",
173                    declaration.tag_name, package.name
174                )
175            })?;
176            let declaration_name = declaration.name.as_deref().unwrap_or(&declaration.tag_name);
177            let template_path = resolve_fast_template(package.root, module_path, declaration_name)
178                .with_context(|| {
179                    format!(
180                        "Failed to locate FAST template for <{}> in package '{}'",
181                        declaration.tag_name, package.name
182                    )
183                })?;
184            if !seen_templates.insert(template_path.clone()) {
185                bail!(
186                    "FAST template {} maps to multiple component declarations",
187                    template_path.display()
188                );
189            }
190            let html_content = read_required_file(&template_path, "FAST component template")?;
191            let css_content = read_optional_file(
192                resolve_fast_styles(&template_path).as_deref(),
193                "FAST component styles",
194            )?;
195            components.push(DiscoveredComponent {
196                tag_name: declaration.tag_name,
197                html_content,
198                css_content,
199                is_client_owned: package.is_client_owned,
200                source: package.name.to_string(),
201            });
202        }
203        Ok(components)
204    }
205}
206
207fn discover_local_templates(
208    root: &Path,
209    tag_name_for_path: fn(&Path) -> Option<&str>,
210) -> Result<Vec<DiscoveredComponent>> {
211    let source = root.display().to_string();
212    let mut components = Vec::new();
213    for entry in WalkDir::new(root).sort_by_file_name() {
214        let entry = entry.with_context(|| format!("Failed to scan {}", root.display()))?;
215        let path = entry.path();
216        if !path.is_file()
217            || !path
218                .extension()
219                .is_some_and(|extension| extension == "html")
220        {
221            continue;
222        }
223        let Some(tag_name) = tag_name_for_path(path) else {
224            continue;
225        };
226        let html_content = read_required_file(path, "component template")?;
227        let css_content =
228            read_optional_file(resolve_local_styles(path).as_deref(), "component styles")?;
229        components.push(DiscoveredComponent {
230            tag_name: tag_name.to_string(),
231            html_content,
232            css_content,
233            is_client_owned: has_sibling_script(path)?,
234            source: source.clone(),
235        });
236    }
237    Ok(components)
238}
239
240fn webui_local_tag(path: &Path) -> Option<&str> {
241    path.file_stem()
242        .and_then(|stem| stem.to_str())
243        .filter(|stem| stem.contains('-'))
244}
245
246fn fast_local_tag(path: &Path) -> Option<&str> {
247    let file_name = path.file_name()?.to_str()?;
248    file_name
249        .strip_suffix(".template.html")
250        .or_else(|| webui_local_tag(path))
251}
252
253fn resolve_local_styles(template_path: &Path) -> Option<PathBuf> {
254    let standard = template_path.with_extension("css");
255    if standard.is_file() {
256        return Some(standard);
257    }
258    resolve_fast_styles(template_path)
259}
260
261fn resolve_fast_styles(template_path: &Path) -> Option<PathBuf> {
262    fast_style_candidates(template_path)
263        .into_iter()
264        .find(|candidate| candidate.is_file())
265}
266
267fn fast_style_candidates(template_path: &Path) -> Vec<PathBuf> {
268    let Some(file_name) = template_path.file_name().and_then(|name| name.to_str()) else {
269        return Vec::new();
270    };
271    let Some(prefix) = file_name.strip_suffix(".template.html") else {
272        return Vec::new();
273    };
274    let Some(parent) = template_path.parent() else {
275        return Vec::new();
276    };
277    let mut candidates = Vec::with_capacity(2);
278    for suffix in [".styles.css", ".css"] {
279        candidates.push(parent.join(format!("{prefix}{suffix}")));
280    }
281    candidates
282}
283
284fn fast_template_candidates(
285    root: &Path,
286    module_path: &Path,
287    declaration_name: &str,
288) -> Vec<PathBuf> {
289    let Some(module) = package_path(root, module_path) else {
290        return Vec::new();
291    };
292    let parent = module.parent().unwrap_or(root);
293    let module_stem = module.file_stem().and_then(|stem| stem.to_str());
294    let declaration_stem = to_kebab_case(declaration_name);
295    let mut candidates = Vec::with_capacity(5);
296    for stem in module_stem
297        .into_iter()
298        .chain(std::iter::once(declaration_stem.as_str()))
299    {
300        push_template_candidate(&mut candidates, parent, stem);
301    }
302
303    // Some generated manifests expose a virtual public module path while the
304    // package stores the implementation under a compact or base class noun.
305    if !module.is_file() {
306        let component_root = if parent == root {
307            root
308        } else {
309            parent
310                .parent()
311                .filter(|candidate| candidate.starts_with(root))
312                .unwrap_or(root)
313        };
314        push_nested_template_candidate(&mut candidates, component_root, declaration_stem.as_str());
315        let compact_stem = declaration_stem.replace('-', "");
316        push_nested_template_candidate(&mut candidates, component_root, &compact_stem);
317        if let Some((_, suffix)) = declaration_stem.rsplit_once('-') {
318            push_nested_template_candidate(&mut candidates, component_root, suffix);
319        }
320    }
321    candidates
322}
323
324fn package_path(root: &Path, relative: &Path) -> Option<PathBuf> {
325    if relative.is_absolute()
326        || relative.components().any(|component| {
327            matches!(
328                component,
329                Component::Prefix(_) | Component::RootDir | Component::ParentDir
330            )
331        })
332    {
333        return None;
334    }
335    Some(root.join(relative))
336}
337
338fn push_template_candidate(candidates: &mut Vec<PathBuf>, parent: &Path, stem: &str) {
339    let candidate = parent.join(format!("{stem}.template.html"));
340    if !candidates.contains(&candidate) {
341        candidates.push(candidate);
342    }
343}
344
345fn push_nested_template_candidate(candidates: &mut Vec<PathBuf>, root: &Path, stem: &str) {
346    push_template_candidate(candidates, &root.join(stem), stem);
347}
348
349fn resolve_fast_template(
350    root: &Path,
351    module_path: &Path,
352    declaration_name: &str,
353) -> Result<PathBuf> {
354    for candidate in fast_template_candidates(root, module_path, declaration_name) {
355        if candidate.is_file() {
356            return Ok(candidate);
357        }
358    }
359    bail!("no sibling <component>.template.html file found")
360}
361
362fn to_kebab_case(name: &str) -> String {
363    let mut result = String::with_capacity(name.len() + 4);
364    for (index, character) in name.chars().enumerate() {
365        if character.is_ascii_uppercase() {
366            if index != 0 {
367                result.push('-');
368            }
369            result.push(character.to_ascii_lowercase());
370        } else {
371            result.push(character);
372        }
373    }
374    result
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use std::fs;
381
382    #[test]
383    fn fast_local_template_uses_component_filename_prefix() {
384        let root = tempfile::TempDir::new().unwrap();
385        fs::write(
386            root.path().join("todo-item.template.html"),
387            "<f-template><template>item</template></f-template>",
388        )
389        .unwrap();
390
391        let components = FastDiscoveryPlugin::new()
392            .discover_local(root.path())
393            .unwrap();
394
395        assert_eq!(components.len(), 1);
396        assert_eq!(components[0].tag_name, "todo-item");
397    }
398
399    #[test]
400    fn virtual_root_module_candidates_stay_inside_package() {
401        let root = tempfile::TempDir::new().unwrap();
402        let candidates = fast_template_candidates(root.path(), Path::new("index.js"), "MyButton");
403
404        assert!(candidates
405            .iter()
406            .all(|candidate| candidate.starts_with(root.path())));
407        assert!(candidates.contains(
408            &root
409                .path()
410                .join("my-button")
411                .join("my-button.template.html")
412        ));
413        assert!(
414            fast_template_candidates(root.path(), Path::new("../escape/index.js"), "Escape")
415                .is_empty()
416        );
417    }
418}