Skip to main content

dmc_transform/builtin/
component_source.rs

1//! `<ComponentSource>` resolver. See `transformers/component-source.md`
2//! for full docs.
3
4use crate::pipeline::Transformer;
5use crate::visit::{NodeAction, Visitor, walk_root};
6use dmc_diagnostic::metadata::{Origin, SourceMeta};
7use dmc_diagnostic::{Code, DiagResult};
8use dmc_parser::ast::*;
9use duck_diagnostic::{Diagnostic, Label, diag};
10use std::path::PathBuf;
11use std::sync::Arc;
12
13/// Resolve `<ComponentSource path="..." />` JSX nodes by reading the
14/// referenced file (or directory of files) and injecting one
15/// `CodeBlock` child per file. The JSX wrapper stays so consumers can
16/// render Preview/Code chrome around the resolved source. PrettyCode
17/// then highlights every injected `CodeBlock` natively.
18#[derive(Default)]
19pub struct ComponentSource {
20  pub base_dir: Option<PathBuf>,
21}
22
23impl ComponentSource {
24  pub fn with_base_dir(p: impl Into<PathBuf>) -> Self {
25    Self { base_dir: Some(p.into()) }
26  }
27
28  fn attr_value(attrs: &[JsxAttr], name: &str) -> Option<String> {
29    attrs.iter().find(|a| a.name == name).and_then(|a| match &a.value {
30      JsxAttrValue::String(s) => Some(s.clone()),
31      _ => None,
32    })
33  }
34}
35
36impl Transformer for ComponentSource {
37  fn name(&self) -> &str {
38    "component-source"
39  }
40  fn transform(
41    &self,
42    doc: &mut Document,
43    meta: &SourceMeta,
44    diag_engine: &mut duck_diagnostic::DiagnosticEngine<Code>,
45  ) {
46    let base_dir = self.base_dir.clone().or_else(|| match &meta.origin {
47      Origin::File(p) => p.parent().map(|p| p.to_path_buf()),
48      _ => None,
49    });
50
51    if base_dir.is_none() && self.base_dir.is_none() {
52      diag_engine.emit(diag!(
53        Code::BaseDirNotFound,
54        format!(
55          "component-source: source has no on-disk parent (origin = {:?}); relative `path=` cannot be resolved",
56          meta.origin
57        )
58      ));
59    }
60
61    let mut v = Apply { base_dir, meta_path: meta.path.clone(), pending: Vec::new() };
62    walk_root(&mut doc.children, &mut v);
63    for d in v.pending.drain(..) {
64      diag_engine.emit(d);
65    }
66  }
67}
68
69struct Apply {
70  base_dir: Option<PathBuf>,
71  meta_path: Arc<str>,
72  pending: Vec<Diagnostic<Code>>,
73}
74
75/// Build a `CodeBlock` node from a single file's content. `rel_label` is
76/// the user-visible filename emitted as the block's `title=` meta so the
77/// React `<ComponentSource>` wrapper can label tabs by basename.
78#[allow(clippy::result_large_err)]
79fn make_code_block(abs: &PathBuf, rel_label: &str, span: &duck_diagnostic::Span) -> DiagResult<Node> {
80  let content =
81    std::fs::read_to_string(abs).map_err(|e| diag!(Code::IoRead, format!("read {}: {}", abs.display(), e)))?;
82  let lang = abs.extension().and_then(|s| s.to_str()).map(String::from);
83  Ok(Node::CodeBlock(CodeBlock {
84    lang,
85    meta: Some(format!("title=\"{}\"", rel_label)),
86    value: content,
87    span: span.clone(),
88  }))
89}
90
91/// Read a path that may be a single file or a directory of files. For
92/// directories, yields one `CodeBlock` per direct child file (sorted).
93#[allow(clippy::result_large_err)]
94fn collect_blocks(abs: &PathBuf, rel: &str, span: &duck_diagnostic::Span) -> DiagResult<Vec<Node>> {
95  let stat = std::fs::metadata(abs).map_err(|e| diag!(Code::IoRead, format!("stat {}: {}", abs.display(), e)))?;
96  if stat.is_dir() {
97    let mut entries: Vec<_> = std::fs::read_dir(abs)
98      .map_err(|e| diag!(Code::IoRead, format!("read_dir {}: {}", abs.display(), e)))?
99      .filter_map(|e| e.ok())
100      .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
101      .collect();
102    entries.sort_by_key(|e| e.file_name());
103    let mut blocks = Vec::with_capacity(entries.len());
104    for e in entries {
105      let path = e.path();
106      let label = e.file_name().to_string_lossy().into_owned();
107      blocks.push(make_code_block(&path, &label, span)?);
108    }
109    Ok(blocks)
110  } else {
111    let label = abs.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| rel.to_string());
112    Ok(vec![make_code_block(abs, &label, span)?])
113  }
114}
115
116impl Visitor for Apply {
117  fn visit_node(&mut self, node: &mut Node) -> NodeAction {
118    let (path_attr, attrs, span, was_self_closing) = match node {
119      Node::JsxSelfClosing(j) if j.name == "ComponentSource" => {
120        (ComponentSource::attr_value(&j.attrs, "path"), std::mem::take(&mut j.attrs), j.span.clone(), true)
121      },
122      Node::JsxElement(j) if j.name == "ComponentSource" => {
123        (ComponentSource::attr_value(&j.attrs, "path"), std::mem::take(&mut j.attrs), j.span.clone(), false)
124      },
125      _ => return NodeAction::Keep,
126    };
127
128    let Some(rel) = path_attr else {
129      self.pending.push(
130        diag!(Code::MissingComponentAttr, "component-source: missing required `path` attribute".to_string())
131          .with_label(Label::primary(span, Some("on this <ComponentSource>".into()))),
132      );
133      // Restore attrs since we took them
134      if let Node::JsxSelfClosing(j) = node {
135        j.attrs = attrs;
136      } else if let Node::JsxElement(j) = node {
137        j.attrs = attrs;
138      }
139      return NodeAction::Keep;
140    };
141
142    let abs = match &self.base_dir {
143      Some(b) => b.join(&rel),
144      None => PathBuf::from(&rel),
145    };
146    let children = match collect_blocks(&abs, &rel, &span) {
147      Ok(bs) => bs,
148      Err(e) => {
149        self.pending.push(
150          diag!(
151            Code::ComponentSourceUnreadable,
152            format!("component-source: cannot read {} ({})", abs.display(), e.message)
153          )
154          .with_label(Label::primary(span.clone(), Some(format!("from {}", self.meta_path)))),
155        );
156        if was_self_closing {
157          if let Node::JsxSelfClosing(j) = node {
158            j.attrs = attrs;
159          }
160        } else if let Node::JsxElement(j) = node {
161          j.attrs = attrs;
162        }
163        return NodeAction::Keep;
164      },
165    };
166
167    // Replace self-closing / empty-element with a populated JsxElement
168    // so the React `<ComponentSource>` wrapper sees the resolved code
169    // blocks as `children`. Pretty-code highlights them on its turn.
170    *node = Node::JsxElement(JsxElement { name: "ComponentSource".into(), attrs, children, span });
171    NodeAction::KeepSkipChildren
172  }
173}