Skip to main content

weimo_wdf/adapters/
markdown.rs

1use crate::{
2    diagnostic::{Diagnostic, Diagnostics},
3    model::*,
4    validate::validate_and_canonicalize,
5};
6use pulldown_cmark::{Event, Parser, Tag, TagEnd};
7use serde_json::json;
8
9/// Parses Markdown into a canonical [`WdfDocument`].
10///
11/// The accepted subset matches what WDF v0.1 can represent: ATX headings,
12/// paragraphs, blockquotes containing paragraphs, top-level bullet and ordered
13/// lists whose items are single paragraphs, `**bold**` spans, and soft/hard
14/// line breaks. Anything else — inline code, links, images, tables, nested
15/// lists, italic, HTML — is rejected with a `markdown.unsupported_*`
16/// diagnostic instead of being silently dropped.
17///
18/// ```
19/// use weimo_wdf::{WdfBlock, from_markdown};
20///
21/// let doc = from_markdown("# Title\n\nplain paragraph\n").expect("supported");
22/// assert!(matches!(doc.root.content[1], WdfBlock::Paragraph { .. }));
23/// ```
24pub fn from_markdown(input: &str) -> Result<WdfDocument, Diagnostics> {
25    let events: Vec<Event<'_>> = Parser::new(input).collect();
26    let mut cursor = Cursor {
27        events,
28        index: 0,
29        diagnostics: Vec::new(),
30    };
31    let blocks = cursor.parse_blocks(None);
32    if !cursor.diagnostics.is_empty() {
33        return Err(cursor.diagnostics);
34    }
35    let value = json!({"schema": WDF_SCHEMA, "version": WDF_VERSION, "root": {"type":"doc", "content": blocks}});
36    validate_and_canonicalize(&value)
37}
38
39struct Cursor<'a> {
40    events: Vec<Event<'a>>,
41    index: usize,
42    diagnostics: Diagnostics,
43}
44
45impl Cursor<'_> {
46    fn parse_blocks(&mut self, terminator: Option<TagEnd>) -> Vec<WdfBlock> {
47        let mut blocks = Vec::new();
48        while self.index < self.events.len() {
49            if matches!(terminator, Some(end) if matches!((&self.events[self.index], end), (Event::End(actual), expected) if *actual == expected))
50            {
51                self.index += 1;
52                break;
53            }
54            let event = self.events[self.index].clone();
55            match event {
56                Event::Start(Tag::Paragraph) => {
57                    self.index += 1;
58                    blocks.push(WdfBlock::Paragraph {
59                        content: self.parse_inline(TagEnd::Paragraph, false),
60                    });
61                }
62                Event::Start(Tag::Heading { level, .. }) => {
63                    self.index += 1;
64                    blocks.push(WdfBlock::Heading {
65                        attrs: HeadingAttrs { level: level as u8 },
66                        content: self.parse_inline(TagEnd::Heading(level), false),
67                    });
68                }
69                Event::Start(Tag::BlockQuote(_)) => {
70                    self.index += 1;
71                    let content = self.parse_blocks(Some(TagEnd::BlockQuote(None)));
72                    let mut paragraphs = Vec::new();
73                    for block in content {
74                        match block {
75                            WdfBlock::Paragraph { content } => {
76                                paragraphs.extend(split_paragraph_lines(content))
77                            }
78                            _ => self.unsupported("blockquote child"),
79                        }
80                    }
81                    blocks.push(WdfBlock::Blockquote {
82                        content: paragraphs,
83                    });
84                }
85                Event::Start(Tag::List(start)) => {
86                    self.index += 1;
87                    let mut items = Vec::new();
88                    while self.index < self.events.len() {
89                        if matches!(self.events[self.index], Event::End(TagEnd::List(_))) {
90                            self.index += 1;
91                            break;
92                        } else if matches!(self.events[self.index], Event::Start(Tag::Item)) {
93                            self.index += 1;
94                            // pulldown-cmark omits paragraph events in tight
95                            // list items, so inline content is parsed directly.
96                            if !matches!(
97                                self.events.get(self.index),
98                                Some(Event::Start(Tag::Paragraph))
99                            ) {
100                                items.push(list_item(self.parse_inline(TagEnd::Item, false)));
101                                continue;
102                            }
103                            let item_blocks = self.parse_blocks(Some(TagEnd::Item));
104                            if item_blocks.len() == 1 {
105                                if let WdfBlock::Paragraph { content } =
106                                    item_blocks.into_iter().next().unwrap()
107                                {
108                                    items.push(list_item(content));
109                                } else {
110                                    self.unsupported("list item");
111                                }
112                            } else {
113                                self.unsupported("list item content");
114                            }
115                        } else {
116                            self.unsupported("list content");
117                            self.index += 1;
118                        }
119                    }
120                    if start.is_some() {
121                        blocks.push(WdfBlock::OrderedList {
122                            attrs: Some(OrderedListAttrs {
123                                start: start.unwrap_or(1) as u32,
124                            }),
125                            content: items,
126                        });
127                    } else {
128                        blocks.push(WdfBlock::BulletList { content: items });
129                    }
130                }
131                Event::Text(text) => {
132                    self.index += 1;
133                    blocks.push(WdfBlock::Paragraph {
134                        content: vec![WdfText::plain(text.to_string())],
135                    });
136                }
137                Event::End(_) => {
138                    self.index += 1;
139                }
140                _ => {
141                    self.unsupported("block");
142                    self.index += 1;
143                }
144            }
145        }
146        blocks
147    }
148
149    fn parse_inline(&mut self, terminator: TagEnd, bold: bool) -> Vec<WdfText> {
150        let mut result = Vec::new();
151        while self.index < self.events.len() {
152            let event = self.events[self.index].clone();
153            if matches!(event, Event::End(end) if end == terminator) {
154                self.index += 1;
155                break;
156            }
157            match event {
158                Event::Text(text) => {
159                    self.index += 1;
160                    result.push(if bold {
161                        WdfText::bold(text.to_string())
162                    } else {
163                        WdfText::plain(text.to_string())
164                    });
165                }
166                Event::Start(Tag::Strong) => {
167                    self.index += 1;
168                    result.extend(self.parse_inline(TagEnd::Strong, true));
169                }
170                Event::SoftBreak | Event::HardBreak => {
171                    self.index += 1;
172                    result.push(if bold {
173                        WdfText::bold("\n")
174                    } else {
175                        WdfText::plain("\n")
176                    });
177                }
178                Event::Code(code) => {
179                    self.unsupported("code");
180                    self.index += 1;
181                    let _ = code;
182                }
183                Event::End(_) => {
184                    self.index += 1;
185                }
186                _ => {
187                    self.unsupported("inline");
188                    self.index += 1;
189                }
190            }
191        }
192        result
193    }
194
195    fn unsupported(&mut self, kind: &str) {
196        self.diagnostics.push(Diagnostic::error(
197            "markdown",
198            &format!("markdown.unsupported_{kind}"),
199            format!("Markdown {kind} is outside the supported subset"),
200        ));
201    }
202}
203
204fn split_paragraph_lines(content: Vec<WdfText>) -> Vec<WdfParagraph> {
205    let mut paragraphs: Vec<Vec<WdfText>> = vec![Vec::new()];
206    for text in content {
207        let parts: Vec<&str> = text.text.split('\n').collect();
208        for (index, part) in parts.iter().enumerate() {
209            if !part.is_empty() {
210                paragraphs
211                    .last_mut()
212                    .expect("paragraph")
213                    .push(if text.is_bold() {
214                        WdfText::bold(*part)
215                    } else {
216                        WdfText::plain(*part)
217                    });
218            }
219            if index + 1 < parts.len() {
220                paragraphs.push(Vec::new());
221            }
222        }
223    }
224    paragraphs
225        .into_iter()
226        .filter(|content| !content.is_empty())
227        .map(paragraph)
228        .collect()
229}
230
231/// Renders a [`WdfDocument`] back to Markdown.
232///
233/// Blocks are separated by one blank line, headings use `#` prefixes, list
234/// items use `-` / `N.` markers, quotes are prefixed with `> `, and bold runs
235/// are wrapped in `**` (inner `**` escaped as `\**`). The output stays inside
236/// the supported subset, so [`from_markdown`] round-trips it losslessly.
237pub fn to_markdown(document: &WdfDocument) -> String {
238    let mut output = document
239        .root
240        .content
241        .iter()
242        .map(block_markdown)
243        .collect::<Vec<_>>()
244        .join("\n\n");
245    output.push('\n');
246    output
247}
248
249fn inline_markdown(content: &[WdfText]) -> String {
250    content
251        .iter()
252        .map(|text| {
253            if text.is_bold() {
254                format!("**{}**", text.text.replace("**", "\\**"))
255            } else {
256                text.text.clone()
257            }
258        })
259        .collect()
260}
261fn block_markdown(block: &WdfBlock) -> String {
262    match block {
263        WdfBlock::Paragraph { content } => inline_markdown(content),
264        WdfBlock::Heading { attrs, content } => format!(
265            "{} {}",
266            "#".repeat(attrs.level as usize),
267            inline_markdown(content)
268        ),
269        WdfBlock::Blockquote { content } => content
270            .iter()
271            .map(|paragraph| {
272                let body = inline_markdown(&paragraph.content);
273                body.split('\n')
274                    .map(|line| {
275                        if line.is_empty() {
276                            ">".to_string()
277                        } else {
278                            format!("> {line}")
279                        }
280                    })
281                    .collect::<Vec<_>>()
282                    .join("\n")
283            })
284            .collect::<Vec<_>>()
285            .join("\n"),
286        WdfBlock::BulletList { content } => content
287            .iter()
288            .map(|item| format!("- {}", inline_markdown(&item.content[0].content)))
289            .collect::<Vec<_>>()
290            .join("\n"),
291        WdfBlock::OrderedList { attrs, content } => content
292            .iter()
293            .enumerate()
294            .map(|(index, item)| {
295                format!(
296                    "{}. {}",
297                    attrs.as_ref().map_or(1, |a| a.start) + index as u32,
298                    inline_markdown(&item.content[0].content)
299                )
300            })
301            .collect::<Vec<_>>()
302            .join("\n"),
303    }
304}