Skip to main content

dmc_transform/builtin/
component_preview.rs

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