Skip to main content

dmc_transform/builtin/
component_preview.rs

1//! `<ComponentPreview>` resolver. See `transformers/component-preview.md`
2//! for full docs.
3
4use crate::pipeline::Transformer;
5use crate::visit::{NodeAction, Visitor, walk_root};
6use dmc_diagnostic::Code;
7use dmc_diagnostic::metadata::SourceMeta;
8use dmc_parser::ast::*;
9use duck_diagnostic::{Diagnostic, Label, diag};
10use std::path::PathBuf;
11
12/// Replace `<ComponentPreview name="X" />` with a `CodeBlock` carrying the
13/// source of registry component `X`. `registry_index` is the JSON manifest;
14/// `registry_root` is the directory referenced paths resolve against.
15#[derive(Default)]
16pub struct ComponentPreview {
17  pub registry_index: Option<PathBuf>,
18  pub registry_root: Option<PathBuf>,
19}
20
21impl ComponentPreview {
22  /// Both paths required. `Default` leaves them `None` so the pass no-ops.
23  pub fn new(registry_index: PathBuf, registry_root: PathBuf) -> Self {
24    Self { registry_index: Some(registry_index), registry_root: Some(registry_root) }
25  }
26
27  /// Lookup by name. Index may be a JSON array of `{name, ...}` objects or
28  /// an object keyed by name.
29  fn lookup_entry<'a>(index: &'a serde_json::Value, name: &str) -> Option<&'a serde_json::Value> {
30    if let Some(arr) = index.as_array() {
31      arr.iter().find(|e| e.get("name").and_then(|v| v.as_str()) == Some(name))
32    } else if let Some(obj) = index.as_object() {
33      obj.get(name)
34    } else {
35      None
36    }
37  }
38
39  fn attr_value(attrs: &[JsxAttr], name: &str) -> Option<String> {
40    attrs.iter().find(|a| a.name == name).and_then(|a| match &a.value {
41      JsxAttrValue::String(s) => Some(s.clone()),
42      _ => None,
43    })
44  }
45}
46
47impl Transformer for ComponentPreview {
48  fn name(&self) -> &str {
49    "component-preview"
50  }
51  fn transform(
52    &self,
53    doc: &mut Document,
54    _meta: &SourceMeta,
55    diag_engine: &mut duck_diagnostic::DiagnosticEngine<Code>,
56  ) {
57    // Both paths required; missing either silently no-ops the pass.
58    let Some(idx) = &self.registry_index else { return };
59    let Some(root) = &self.registry_root else { return };
60    let raw = match std::fs::read_to_string(idx) {
61      Ok(r) => r,
62      Err(e) => {
63        diag_engine.emit(diag!(
64          Code::RegistryIndexUnreadable,
65          format!("component-preview: cannot read registry index {} ({})", idx.display(), e)
66        ));
67        return;
68      },
69    };
70    let index: serde_json::Value = match serde_json::from_str(&raw) {
71      Ok(v) => v,
72      Err(e) => {
73        diag_engine.emit(diag!(
74          Code::RegistryIndexMalformed,
75          format!("component-preview: registry index {} is not valid JSON ({})", idx.display(), e)
76        ));
77        return;
78      },
79    };
80    let mut v = Apply { index, root: root.clone(), pending: Vec::new() };
81    walk_root(&mut doc.children, &mut v);
82    for d in v.pending.drain(..) {
83      diag_engine.emit(d);
84    }
85  }
86}
87
88struct Apply {
89  index: serde_json::Value,
90  root: PathBuf,
91  pending: Vec<Diagnostic<Code>>,
92}
93
94impl Visitor for Apply {
95  fn visit_node(&mut self, node: &mut Node) -> NodeAction {
96    let (name_opt, span) = match node {
97      Node::JsxSelfClosing(j) if j.name == "ComponentPreview" => {
98        (ComponentPreview::attr_value(&j.attrs, "name"), j.span.clone())
99      },
100      Node::JsxElement(j) if j.name == "ComponentPreview" => {
101        (ComponentPreview::attr_value(&j.attrs, "name"), j.span.clone())
102      },
103      _ => return NodeAction::Keep,
104    };
105    let Some(name) = name_opt else {
106      self.pending.push(
107        diag!(Code::MissingComponentAttr, "component-preview: missing required `name` attribute".to_string())
108          .with_label(Label::primary(span, Some("on this <ComponentPreview>".into()))),
109      );
110      return NodeAction::Keep;
111    };
112    let Some(entry) = ComponentPreview::lookup_entry(&self.index, &name) else {
113      self.pending.push(
114        diag!(Code::RegistryEntryNotFound, format!("component-preview: registry has no entry for `{}`", name))
115          .with_label(Label::primary(span, Some("not found".into()))),
116      );
117      return NodeAction::Keep;
118    };
119    let files = entry.get("files").and_then(|v| v.as_array());
120    let Some(files) = files else {
121      self
122        .pending
123        .push(diag!(Code::RegistryEntryNotFound, format!("component-preview: entry `{}` has no `files` array", name)));
124      return NodeAction::Keep;
125    };
126    let Some(first) = files.first() else {
127      self.pending.push(diag!(
128        Code::RegistryEntryNotFound,
129        format!("component-preview: entry `{}` has empty `files` array", name)
130      ));
131      return NodeAction::Keep;
132    };
133    let path = first.get("path").and_then(|v| v.as_str()).unwrap_or("");
134    let abs = self.root.join(path);
135    match std::fs::read_to_string(&abs) {
136      Ok(content) => {
137        let lang = abs.extension().and_then(|s| s.to_str()).map(String::from);
138        *node = Node::CodeBlock(CodeBlock { lang, meta: Some(format!("title=\"{name}\"")), value: content, span });
139        NodeAction::KeepSkipChildren
140      },
141      Err(e) => {
142        self.pending.push(
143          diag!(Code::RegistrySourceUnreadable, format!("component-preview: cannot read {} ({})", abs.display(), e))
144            .with_label(Label::primary(span, Some(format!("for `{}`", name)))),
145        );
146        NodeAction::Keep
147      },
148    }
149  }
150}