1use mq_markdown::{AttrValue, Markdown, Node};
2use nu_engine::command_prelude::*;
3
4#[derive(Clone)]
5pub struct FromMd;
6
7impl Command for FromMd {
8 fn name(&self) -> &str {
9 "from md"
10 }
11
12 fn description(&self) -> &str {
13 "Convert markdown text into structured data."
14 }
15
16 fn signature(&self) -> Signature {
17 Signature::build("from md")
18 .input_output_types(vec![(Type::String, Type::table())])
19 .category(Category::Formats)
20 }
21
22 fn examples(&self) -> Vec<Example<'_>> {
23 vec![
24 Example {
25 example: "'# Title' | from md | select type position attrs",
26 description: "Parse markdown and return key node fields.",
27 result: Some(Value::test_list(vec![heading_title_overview_node()])),
28 },
29 Example {
30 example: "'---
31title: Demo
32---
33# A' | from md | get 0.type",
34 description: "Parse markdown frontmatter as a dedicated node.",
35 result: Some(Value::test_string("yaml")),
36 },
37 ]
38 }
39
40 fn run(
41 &self,
42 _engine_state: &EngineState,
43 _stack: &mut Stack,
44 call: &Call,
45 input: PipelineData,
46 ) -> Result<PipelineData, ShellError> {
47 from_md(input, call.head)
48 }
49}
50
51fn title_position(line_start: i64, column_start: i64, line_end: i64, column_end: i64) -> Value {
52 Value::test_record(record! {
53 "start" => Value::test_record(record! {
54 "line" => Value::test_int(line_start),
55 "column" => Value::test_int(column_start),
56 }),
57 "end" => Value::test_record(record! {
58 "line" => Value::test_int(line_end),
59 "column" => Value::test_int(column_end),
60 }),
61 })
62}
63
64fn heading_title_overview_node() -> Value {
65 Value::test_record(record! {
66 "type" => Value::test_string("h1"),
67 "position" => title_position(1, 1, 1, 8),
68 "attrs" => Value::test_record(record! {
69 "depth" => Value::test_int(1),
70 "level" => Value::test_int(1),
71 }),
72 })
73}
74
75fn from_md(input: PipelineData, head: Span) -> Result<PipelineData, ShellError> {
76 let (string_input, span, metadata) = input.collect_string_strict(head)?;
77
78 let markdown =
79 Markdown::from_markdown_str(&string_input).map_err(|err| ShellError::CantConvert {
80 to_type: "structured markdown data".into(),
81 from_type: "string".into(),
82 span,
83 help: Some(err.to_string()),
84 })?;
85
86 let value = markdown_to_ast_value(&markdown, span);
87
88 Ok(value.into_pipeline_data_with_metadata(metadata.map(|md| md.with_content_type(None))))
89}
90
91fn markdown_to_ast_value(markdown: &Markdown, span: Span) -> Value {
92 let nodes = markdown
93 .nodes
94 .iter()
95 .map(|node| node_to_ast_value(node, span))
96 .collect();
97
98 Value::list(nodes, span)
99}
100
101fn node_to_ast_value(node: &Node, span: Span) -> Value {
102 let children = node.children();
103
104 let mut record = Record::new();
105 record.push("type", Value::string(node_type_name(node), span));
106
107 if let Some(position) = node.position() {
108 record.push("position", position_to_value(position, span));
109 } else {
110 record.push("position", Value::nothing(span));
111 }
112
113 let attrs = node_attrs_to_value(node, children.is_empty(), span);
114 record.push("attrs", attrs);
115
116 let children = children
117 .iter()
118 .map(|child| node_to_ast_value(child, span))
119 .collect();
120 record.push("children", Value::list(children, span));
121
122 Value::record(record, span)
123}
124
125fn node_type_name(node: &Node) -> String {
126 if node.is_empty() {
127 return "empty".to_string();
128 }
129
130 if node.is_fragment() {
131 return "fragment".to_string();
132 }
133
134 node.name().to_string()
135}
136
137fn position_to_value(position: mq_markdown::Position, span: Span) -> Value {
138 Value::record(
139 record! {
140 "start" => Value::record(
141 record! {
142 "line" => Value::int(position.start.line as i64, span),
143 "column" => Value::int(position.start.column as i64, span),
144 },
145 span,
146 ),
147 "end" => Value::record(
148 record! {
149 "line" => Value::int(position.end.line as i64, span),
150 "column" => Value::int(position.end.column as i64, span),
151 },
152 span,
153 ),
154 },
155 span,
156 )
157}
158
159fn node_attrs_to_value(node: &Node, is_leaf: bool, span: Span) -> Value {
160 const ATTRIBUTE_KEYS: &[&str] = &[
161 "depth", "level", "index", "ordered", "checked", "lang", "meta", "fence", "url", "title",
162 "alt", "ident", "label", "row", "column", "align", "name",
163 ];
164
165 let mut attrs = Record::new();
166
167 if is_leaf {
169 if let Some(value) = node.attr("value") {
170 attrs.push("value", attr_value_to_nu_value(value, span));
171 } else if node.is_text() {
172 attrs.push("value", Value::string(node.value(), span));
174 }
175 }
176
177 for key in ATTRIBUTE_KEYS {
178 if let Some(value) = node.attr(key) {
179 attrs.push(*key, attr_value_to_nu_value(value, span));
180 }
181 }
182
183 Value::record(attrs, span)
184}
185
186fn attr_value_to_nu_value(value: AttrValue, span: Span) -> Value {
187 match value {
188 AttrValue::String(value) => Value::string(value, span),
189 AttrValue::Integer(value) => Value::int(value, span),
190 AttrValue::Number(value) => Value::float(value, span),
191 AttrValue::Boolean(value) => Value::bool(value, span),
192 AttrValue::Null => Value::nothing(span),
193 AttrValue::Array(value) => Value::list(
194 value
195 .iter()
196 .map(|node| node_to_ast_value(node, span))
197 .collect(),
198 span,
199 ),
200 }
201}
202
203#[cfg(test)]
204mod test {
205 use super::FromMd;
206
207 #[test]
208 fn test_examples() -> nu_test_support::Result {
209 nu_test_support::test().examples(FromMd)
210 }
211}