wayver's git archive


an obsidian renderer
git clone https://git.wayver.dev/sable

sable-markdown/src/render/block.rs@337ba67f65eaa17b44e371af7c0f0c761d6aa914

raw
Date Commit Message Author Files + -
2026-02-23 01:55 initial mvp wayverd 139 17808 0
...

1use heck::ToTitleCase as _;
2use pretty::{Arena, DocAllocator, DocBuilder};
3
4use crate::{
5    ast::{
6        Alignment, Block, Callout, CodeBlock, FootnoteDefinition, HeadingKind, List,
7        ListBulletKind, ListItem, ListKind, ListOrderedKindOptions, SetextHeading, Table, TableRow,
8        TaskState,
9    },
10    render::{Config, Context, ToDoc, highlighter, util::ArenaExt as _},
11};
12
13impl<'a> ToDoc<'a> for Vec<Block> {
14    fn to_doc(
15        &self,
16        config: &'a Config<'a>,
17        context: &'a Context,
18        arena: &'a Arena<'a>,
19    ) -> DocBuilder<'a, Arena<'a>, ()> {
20        arena.concat(
21            self.iter()
22                .map(|block| block.to_doc(config, context, arena)),
23        )
24    }
25}
26
27impl<'a> ToDoc<'a> for Vec<&Block> {
28    fn to_doc(
29        &self,
30        config: &'a Config<'a>,
31        context: &'a Context,
32        arena: &'a Arena<'a>,
33    ) -> DocBuilder<'a, Arena<'a>, ()> {
34        arena.concat(
35            self.iter()
36                .map(|block| block.to_doc(config, context, arena)),
37        )
38    }
39}
40
41impl<'a> ToDoc<'a> for Block {
42    fn to_doc(
43        &self,
44        config: &'a Config<'a>,
45        context: &'a Context,
46        arena: &'a Arena<'a>,
47    ) -> DocBuilder<'a, Arena<'a>, ()> {
48        match self {
49            Self::Paragraph(inlines) => {
50                let inner = arena.concat(
51                    inlines
52                        .iter()
53                        .map(|inline| inline.to_doc(config, context, arena)),
54                );
55                arena.tag("p", vec![], inner)
56            }
57            // TODO: add heading id
58            Self::Heading(v) => {
59                let htag = match v.kind {
60                    HeadingKind::Atx(1) | HeadingKind::Setext(SetextHeading::Level1) => "h1",
61                    HeadingKind::Atx(2) | HeadingKind::Setext(SetextHeading::Level2) => "h2",
62                    HeadingKind::Atx(3) => "h3",
63                    HeadingKind::Atx(4) => "h4",
64                    HeadingKind::Atx(5) => "h5",
65                    HeadingKind::Atx(_) => "h6",
66                };
67                let inner = arena.concat(
68                    v.content
69                        .iter()
70                        .map(|inline| inline.to_doc(config, context, arena)),
71                );
72                arena.tag(htag, vec![], inner)
73            }
74            Self::ThematicBreak => arena.tag("hr", vec![], arena.nil()),
75            Self::BlockQuote(inner) => {
76                let inner = arena.concat(
77                    inner
78                        .iter()
79                        .map(|inline| inline.to_doc(config, context, arena)),
80                );
81                arena.tag("blockquote", vec![], inner)
82            }
83            Self::List(v) => v.to_doc(config, context, arena),
84            Self::CodeBlock(v) => v.to_doc(config, context, arena),
85            Self::HtmlBlock(html) => arena.text(html.clone()),
86            Self::Table(v) => v.to_doc(config, context, arena),
87            Self::FootnoteDefinition(def) => def.to_doc(config, context, arena),
88            Self::Callout(callout) => callout.to_doc(config, context, arena),
89            Self::Empty | Self::Definition(_) => arena.nil(),
90        }
91    }
92}
93
94impl<'a> ToDoc<'a> for List {
95    fn to_doc(
96        &self,
97        config: &'a Config<'a>,
98        context: &'a Context,
99        arena: &'a Arena<'a>,
100    ) -> DocBuilder<'a, Arena<'a>, ()> {
101        let items = arena.concat(
102            self.items
103                .iter()
104                .map(|item| item.to_doc(config, context, arena)),
105        );
106        match self.kind {
107            ListKind::Ordered(ListOrderedKindOptions { start }) => {
108                arena.tag("ol", vec![("start".to_owned(), format!("{start}"))], items)
109            }
110            ListKind::Bullet(kind) => {
111                let style = match kind {
112                    ListBulletKind::Dash => "list-kind-dash",
113                    ListBulletKind::Star => "list-kind-star",
114                    ListBulletKind::Plus => "list-kind-plus",
115                };
116                arena.tag("ul", vec![("class".to_owned(), style.to_owned())], items)
117            }
118        }
119    }
120}
121
122impl<'a> ToDoc<'a> for ListItem {
123    fn to_doc(
124        &self,
125        config: &'a Config<'a>,
126        context: &'a Context,
127        arena: &'a Arena<'a>,
128    ) -> DocBuilder<'a, Arena<'a>, ()> {
129        let task = match self.task {
130            Some(TaskState::Complete) => arena.tag(
131                "span",
132                vec![("class".to_owned(), "list-task-complete".to_owned())],
133                arena.text("[X] "),
134            ),
135            Some(TaskState::Incomplete) => arena.tag(
136                "span",
137                vec![("class".to_owned(), "list-task-incomplete".to_owned())],
138                arena.text("[ ] "),
139            ),
140            None => arena.nil(),
141        };
142        let content = task.append(
143            arena.concat(
144                self.blocks
145                    .iter()
146                    .map(|block| block.to_doc(config, context, arena)),
147            ),
148        );
149
150        arena.tag("li", vec![], content)
151    }
152}
153
154impl<'a> ToDoc<'a> for CodeBlock {
155    fn to_doc(
156        &self,
157        _config: &'a Config<'a>,
158        _context: &'a Context,
159        arena: &'a Arena<'a>,
160    ) -> DocBuilder<'a, Arena<'a>, ()> {
161        let code = highlighter::highlight(self);
162
163        arena.tag("pre", vec![], arena.tag("code", vec![], arena.text(code)))
164    }
165}
166
167impl<'a> ToDoc<'a> for Table {
168    fn to_doc(
169        &self,
170        config: &'a Config<'a>,
171        context: &'a Context,
172        arena: &'a Arena<'a>,
173    ) -> DocBuilder<'a, Arena<'a>, ()> {
174        let first_row = table_row_to_doc(
175            config,
176            context,
177            arena,
178            self.rows.first().unwrap(),
179            "th",
180            &self.alignments,
181        );
182        let mut acc = arena.nil();
183        for row in self.rows.iter().skip(1) {
184            acc = acc.append(table_row_to_doc(
185                config,
186                context,
187                arena,
188                row,
189                "td",
190                &self.alignments,
191            ));
192        }
193
194        let content = arena
195            .tag("thead", vec![], first_row)
196            .append(arena.tag("tbody", vec![], acc));
197
198        arena.tag("table", vec![], content)
199    }
200}
201
202fn table_row_to_doc<'a>(
203    config: &'a Config<'a>,
204    context: &'a Context,
205    arena: &'a Arena<'a>,
206    row: &TableRow,
207    row_tag: &'static str,
208    alignments: &[Alignment],
209) -> DocBuilder<'a, Arena<'a>, ()> {
210    let mut acc = arena.nil();
211    for (i, cell) in row.iter().enumerate() {
212        let alignment = match alignments.get(i) {
213            None | Some(Alignment::Left | Alignment::None) => "left",
214            Some(Alignment::Right) => "right",
215            Some(Alignment::Center) => "center",
216        };
217        let alignment_class = format!("table-align-{alignment}");
218        let attributes = vec![("class".to_owned(), alignment_class)];
219        acc = acc.append(arena.tag(row_tag, attributes, cell.to_doc(config, context, arena)));
220    }
221
222    arena.tag("tr", vec![], acc)
223}
224
225impl<'a> ToDoc<'a> for FootnoteDefinition {
226    fn to_doc(
227        &self,
228        config: &'a Config<'a>,
229        context: &'a Context,
230        arena: &'a Arena<'a>,
231    ) -> DocBuilder<'a, Arena<'a>, ()> {
232        let Some(index) = context.get_footnote_index(&self.label) else {
233            return arena.nil();
234        };
235
236        arena.tag(
237            "div",
238            vec![
239                ("class".to_owned(), "footnote-definition".to_owned()),
240                ("id".to_owned(), format!("footnote-{index}")),
241            ],
242            arena
243                .tag(
244                    "span",
245                    vec![("class".to_owned(), "footnote-definition-index".to_owned())],
246                    arena.text(format!("{index}. ")),
247                )
248                .append(arena.tag(
249                    "span",
250                    vec![("class".to_owned(), "footnote-definition-content".to_owned())],
251                    self.blocks.to_doc(config, context, arena),
252                )),
253        )
254    }
255}
256
257impl<'a> ToDoc<'a> for Callout {
258    fn to_doc(
259        &self,
260        config: &'a Config<'a>,
261        context: &'a Context,
262        arena: &'a Arena<'a>,
263    ) -> DocBuilder<'a, Arena<'a>, ()> {
264        arena.tag(
265            "div",
266            vec![
267                ("class".to_owned(), "callout".to_owned()),
268                ("data-callout".to_owned(), "info".to_owned()),
269            ],
270            arena
271                .tag(
272                    "div",
273                    vec![("class".to_owned(), "callout-title".to_owned())],
274                    arena
275                        .tag(
276                            "div",
277                            vec![("class".to_owned(), "callout-title-icon".to_owned())],
278                            arena.nil(),
279                        )
280                        .append(
281                            arena.tag(
282                                "div",
283                                vec![("class".to_owned(), "callout-title-inner".to_owned())],
284                                arena.text(
285                                    self.title
286                                        .clone()
287                                        .unwrap_or_else(|| self.level.to_title_case()),
288                                ),
289                            ),
290                        ),
291                )
292                .append(arena.tag(
293                    "div",
294                    vec![("class".to_owned(), "callout-content".to_owned())],
295                    self.blocks.to_doc(config, context, arena),
296                )),
297        )
298    }
299}
300