Skip to main content

dmc_transform/builtin/
npm_command.rs

1//! `npm install` / `npx` -> `<PackageManagerTabs>`. See
2//! `transformers/npm-command.md` 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::*;
9
10/// Detect `npm install ...`, `npx create-...`, and `npx ...` first-lines in
11/// fenced code blocks and replace them with a `<PackageManagerTabs>` JSX
12/// element carrying the per-pm equivalents as plain string attrs.
13#[derive(Default)]
14pub struct NpmCommand;
15
16impl NpmCommand {
17  pub fn new() -> Self {
18    Self
19  }
20}
21
22impl Transformer for NpmCommand {
23  fn name(&self) -> &str {
24    "npm-command"
25  }
26
27  fn transform(
28    &self,
29    doc: &mut Document,
30    _meta: &SourceMeta,
31    _diag_engine: &mut duck_diagnostic::DiagnosticEngine<Code>,
32  ) {
33    let mut v = Apply;
34    walk_root(&mut doc.children, &mut v);
35  }
36}
37
38struct Apply;
39
40impl NpmCommand {
41  /// Map the block's first line to npm/yarn/pnpm/bun equivalents. Mirrors
42  /// velite's `rehype-npm-command`: any `npm install`, `npx create-`, or
43  /// `npx <bin>` first-line triggers the swap.
44  fn derive(value: &str) -> Option<[(&'static str, String); 4]> {
45    let line = value.lines().next()?.trim();
46    if let Some(rest) = line.strip_prefix("npm install") {
47      let pkgs = rest.trim_start();
48      let suffix = if pkgs.is_empty() { String::new() } else { format!(" {pkgs}") };
49      return Some([
50        ("npm", format!("npm install{suffix}")),
51        ("yarn", format!("yarn add{suffix}")),
52        ("pnpm", format!("pnpm add{suffix}")),
53        ("bun", format!("bun add{suffix}")),
54      ]);
55    }
56    if let Some(rest) = line.strip_prefix("npx create-") {
57      let rest = rest.trim();
58      return Some([
59        ("npm", format!("npx create-{rest}")),
60        ("yarn", format!("yarn create {rest}")),
61        ("pnpm", format!("pnpm create {rest}")),
62        ("bun", format!("bunx --bun create-{rest}")),
63      ]);
64    }
65    if let Some(rest) = line.strip_prefix("npx ") {
66      let rest = rest.trim();
67      return Some([
68        ("npm", format!("npx {rest}")),
69        ("yarn", format!("yarn dlx {rest}")),
70        ("pnpm", format!("pnpm dlx {rest}")),
71        ("bun", format!("bunx --bun {rest}")),
72      ]);
73    }
74    None
75  }
76}
77
78impl Visitor for Apply {
79  fn visit_node(&mut self, node: &mut Node) -> NodeAction {
80    let Node::CodeBlock(cb) = node else { return NodeAction::Keep };
81    let Some(variants) = NpmCommand::derive(&cb.value) else { return NodeAction::Keep };
82    let span = cb.span.clone();
83
84    let pm_attrs: Vec<JsxAttr> = variants
85      .iter()
86      .map(|(name, value)| JsxAttr {
87        name: name.to_string(),
88        value: JsxAttrValue::String(value.clone()),
89        span: span.clone(),
90      })
91      .collect();
92
93    let theme_div = |mode: &str| -> Node {
94      Node::JsxElement(JsxElement {
95        name: "div".to_string(),
96        attrs: vec![JsxAttr {
97          name: "data-theme".to_string(),
98          value: JsxAttrValue::String(mode.to_string()),
99          span: span.clone(),
100        }],
101        children: vec![Node::JsxSelfClosing(JsxSelfClosing {
102          name: "PackageManagerTabs".to_string(),
103          attrs: pm_attrs.clone(),
104          span: span.clone(),
105        })],
106        span: span.clone(),
107      })
108    };
109
110    // Wrap matches velite's `rehype-pretty-code` fragment shape: an outer
111    // `data-dmc-fragment` div with one `data-theme="<mode>"`
112    // child per theme. Consumer CSS picks which copy to show by theme; the
113    // tab content itself is theme-independent so both copies carry the
114    // same `<PackageManagerTabs>` payload.
115    let fragment = Node::JsxElement(JsxElement {
116      name: "div".to_string(),
117      attrs: vec![JsxAttr {
118        // Empty-string value matches velite's `data-dmc-fragment=""`
119        // serialization. A `JsxAttrValue::Boolean` would render as `="true"` for
120        // this non-standard data-attr, breaking consumer CSS selectors that
121        // target the empty form (e.g. `[data-dmc-fragment=""]`).
122        name: "data-dmc-fragment".to_string(),
123        value: JsxAttrValue::String(String::new()),
124        span: span.clone(),
125      }],
126      children: vec![theme_div("dark"), theme_div("light")],
127      span,
128    });
129
130    NodeAction::Replace(vec![fragment])
131  }
132}