Skip to main content

bpaf/buffer/
html.rs

1use crate::{
2    buffer::{
3        splitter::{split, Chunk},
4        Block, Skip, Style, Token,
5    },
6    Doc, OptionParser,
7};
8
9#[cfg(feature = "docgen")]
10use crate::{
11    buffer::{extract_sections, Info, Meta},
12    meta_help::render_help,
13    Parser,
14};
15
16#[inline(never)]
17#[cfg(feature = "docgen")]
18fn collect_html(app: String, meta: &Meta, info: &Info) -> Doc {
19    let mut sections = Vec::new();
20    let root = meta;
21    let mut path = vec![app];
22    extract_sections(root, info, &mut path, &mut sections);
23
24    let mut buf = Doc::default();
25
26    if sections.len() > 1 {
27        buf.token(Token::BlockStart(Block::Block));
28        buf.token(Token::BlockStart(Block::Header));
29        buf.text("Command summary");
30        buf.token(Token::BlockEnd(Block::Header));
31        buf.token(Token::BlockEnd(Block::Block));
32
33        // TODO - this defines forward references to sections which are rendered differently
34        // between html and markdown and never used in console...
35        for section in &sections {
36            buf.token(Token::BlockStart(Block::ItemBody));
37            buf.text(&format!(
38                "* [`{}`↴](#{})",
39                section.path.join(" "),
40                section.path.join("-").to_lowercase().replace(' ', "-"),
41            ));
42            buf.token(Token::BlockEnd(Block::ItemBody));
43        }
44    }
45
46    for section in sections {
47        buf.token(Token::BlockStart(Block::Header));
48        buf.text(&section.path.join(" ").to_string());
49        buf.token(Token::BlockEnd(Block::Header));
50
51        let b = render_help(
52            &section.path,
53            section.info,
54            section.meta,
55            &section.info.meta(),
56            false,
57        );
58        buf.doc(&b);
59    }
60    buf
61}
62
63impl<T> OptionParser<T> {
64    /// Render command line documentation for the app into html/markdown mix
65    #[cfg(feature = "docgen")]
66    pub fn render_html(&self, app: impl Into<String>) -> String {
67        collect_html(app.into(), &self.inner.meta(), &self.info).render_html(true, false)
68    }
69
70    /// Render command line documentation for the app into Markdown
71    #[cfg(feature = "docgen")]
72    pub fn render_markdown(&self, app: impl Into<String>) -> String {
73        collect_html(app.into(), &self.inner.meta(), &self.info).render_markdown(true)
74    }
75}
76
77#[derive(Copy, Clone, Default)]
78pub(crate) struct Styles {
79    mono: bool,
80    bold: bool,
81    italic: bool,
82}
83impl From<Style> for Styles {
84    fn from(f: Style) -> Self {
85        match f {
86            Style::Literal => Styles {
87                bold: true,
88                mono: true,
89                italic: false,
90            },
91            Style::Metavar => Styles {
92                bold: false,
93                mono: true,
94                italic: true,
95            },
96            Style::Text => Styles {
97                bold: false,
98                mono: false,
99                italic: false,
100            },
101            Style::Emphasis | Style::Invalid => Styles {
102                mono: false,
103                bold: true,
104                italic: false,
105            },
106        }
107    }
108}
109
110fn change_style(res: &mut String, cur: &mut Styles, new: Styles) {
111    if cur.italic {
112        res.push_str("</i>");
113    }
114    if cur.bold {
115        res.push_str("</b>");
116    }
117    if cur.mono {
118        res.push_str("</tt>");
119    }
120    if new.mono {
121        res.push_str("<tt>");
122    }
123    if new.bold {
124        res.push_str("<b>");
125    }
126    if new.italic {
127        res.push_str("<i>");
128    }
129    *cur = new;
130}
131
132fn change_to_markdown_style(res: &mut String, cur: &mut Styles, new: Styles) {
133    if cur.mono {
134        res.push('`');
135    }
136
137    if cur.bold {
138        res.push_str("**");
139    }
140    if cur.italic {
141        res.push('_');
142    }
143    if new.italic {
144        res.push('_');
145    }
146    if new.bold {
147        res.push_str("**");
148    }
149    if new.mono {
150        res.push('`');
151    }
152    *cur = new;
153}
154
155/// Make it so new text is separated by an empty line
156fn blank_html_line(res: &mut String) {
157    if !(res.is_empty() || res.ends_with("<br>\n")) {
158        res.push_str("<br>\n");
159    }
160}
161
162/// Make it so new text is separated by an empty line
163fn blank_markdown_line(res: &mut String) {
164    if !(res.is_empty() || res.ends_with("\n\n")) {
165        res.push_str("\n\n");
166    }
167}
168
169/// Make it so new text is separated by an empty line
170fn new_markdown_line(res: &mut String) {
171    if !(res.is_empty() || res.ends_with('\n')) {
172        res.push('\n');
173    }
174}
175
176const CSS: &str = "
177<style>
178div.bpaf-doc {
179    padding: 14px;
180    background-color:var(--code-block-background-color);
181    font-family: \"Source Code Pro\", monospace;
182    margin-bottom: 0.75em;
183}
184div.bpaf-doc dt { margin-left: 1em; }
185div.bpaf-doc dd { margin-left: 3em; }
186div.bpaf-doc dl { margin-top: 0; padding-left: 1em; }
187div.bpaf-doc  { padding-left: 1em; }
188</style>";
189
190impl Doc {
191    #[doc(hidden)]
192    /// Render doc into html page, used by documentation sample generator
193    #[must_use]
194    pub fn render_html(&self, full: bool, include_css: bool) -> String {
195        let mut res = String::new();
196        let mut byte_pos = 0;
197        let mut cur_style = Styles::default();
198
199        // skip tracks text paragraphs, paragraphs starting from the section
200        // one are only shown when full is set to true
201        let mut skip = Skip::default();
202
203        // stack keeps track of the AST tree, mostly to be able to tell
204        // if we are rendering definition list or item list
205        let mut stack = Vec::new();
206
207        for token in self.tokens.iter().copied() {
208            match token {
209                Token::Text { bytes, style } => {
210                    let input = &self.payload[byte_pos..byte_pos + bytes];
211                    byte_pos += bytes;
212
213                    if skip.enabled() {
214                        continue;
215                    }
216
217                    change_style(&mut res, &mut cur_style, Styles::from(style));
218
219                    for chunk in split(input) {
220                        match chunk {
221                            Chunk::Raw(input, _) => {
222                                let input = input.replace('<', "&lt;").replace('>', "&gt;");
223                                res.push_str(&input);
224                            }
225                            Chunk::Paragraph => {
226                                if full {
227                                    res.push_str("<br>\n");
228                                } else {
229                                    skip.enable();
230                                    break;
231                                }
232                            }
233                            Chunk::LineBreak => res.push_str("<br>\n"),
234                        }
235                    }
236                }
237                Token::BlockStart(b) => {
238                    change_style(&mut res, &mut cur_style, Styles::default());
239                    match b {
240                        Block::Header => {
241                            blank_html_line(&mut res);
242                            res.push_str("# ");
243                        }
244                        Block::Section2 => {
245                            res.push_str("<div>\n");
246                        }
247                        Block::ItemTerm => res.push_str("<dt>"),
248                        Block::ItemBody => {
249                            if stack.last().copied() == Some(Block::DefinitionList) {
250                                res.push_str("<dd>");
251                            } else {
252                                res.push_str("<li>");
253                            }
254                        }
255                        Block::DefinitionList => {
256                            res.push_str("<dl>");
257                        }
258                        Block::Block => {
259                            res.push_str("<p>");
260                        }
261                        Block::Meta => todo!(),
262                        Block::Section3 | Block::Section4 => {
263                            res.push_str("<div style='padding-left: 0.5em'>")
264                        }
265                        Block::Mono | Block::TermRef => {}
266                        Block::InlineBlock => {
267                            skip.push();
268                        }
269                    }
270                    stack.push(b);
271                }
272                Token::BlockEnd(b) => {
273                    change_style(&mut res, &mut cur_style, Styles::default());
274                    stack.pop();
275                    match b {
276                        Block::Header => {
277                            blank_html_line(&mut res);
278                        }
279                        Block::Section2 => {
280                            res.push_str("</div>");
281                        }
282
283                        Block::InlineBlock => {
284                            skip.pop();
285                        }
286                        Block::ItemTerm => res.push_str("</dt>\n"),
287                        Block::ItemBody => {
288                            if stack.last().copied() == Some(Block::DefinitionList) {
289                                res.push_str("</dd>\n");
290                            } else {
291                                res.push_str("</li>\n");
292                            }
293                        }
294                        Block::DefinitionList => res.push_str("</dl>\n"),
295                        Block::Block => {
296                            res.push_str("</p>");
297                        }
298                        Block::Mono | Block::TermRef => {}
299                        Block::Section3 | Block::Section4 => res.push_str("</div>"),
300                        Block::Meta => todo!(),
301                    }
302                }
303            }
304        }
305        change_style(&mut res, &mut cur_style, Styles::default());
306        if include_css {
307            res.push_str(CSS);
308        }
309        res
310    }
311
312    /// Render doc into markdown document, used by documentation sample generator
313    #[must_use]
314    pub fn render_markdown(&self, full: bool) -> String {
315        let mut res = String::new();
316        let mut byte_pos = 0;
317        let mut cur_style = Styles::default();
318
319        let mut skip = Skip::default();
320        let mut empty_term = false;
321        let mut mono = 0;
322        let mut def_list = false;
323        let mut code_block = false;
324        let mut app_name_seen = false;
325        for (ix, token) in self.tokens.iter().copied().enumerate() {
326            match token {
327                Token::Text { bytes, style } => {
328                    let input = &self.payload[byte_pos..byte_pos + bytes];
329                    byte_pos += bytes;
330                    if skip.enabled() {
331                        continue;
332                    }
333
334                    change_to_markdown_style(&mut res, &mut cur_style, Styles::from(style));
335
336                    for chunk in split(input) {
337                        match chunk {
338                            Chunk::Raw(input, w) => {
339                                if w == Chunk::TICKED_CODE {
340                                    new_markdown_line(&mut res);
341                                    res.push_str("  ");
342                                    res.push_str(input);
343                                    res.push('\n');
344                                } else if w == Chunk::CODE {
345                                    if !code_block {
346                                        res.push_str("\n\n  ```text\n");
347                                    }
348                                    code_block = true;
349                                    res.push_str("  ");
350                                    res.push_str(input);
351                                    res.push('\n');
352                                } else {
353                                    if code_block {
354                                        res.push_str("\n  ```\n");
355                                        code_block = false;
356                                    }
357                                    if mono > 0 {
358                                        let input = input.replace('[', "\\[").replace(']', "\\]");
359                                        res.push_str(&input);
360                                    } else {
361                                        res.push_str(input);
362                                    }
363                                }
364                            }
365                            Chunk::Paragraph => {
366                                if full {
367                                    res.push_str("\n\n");
368                                    if def_list {
369                                        res.push_str("  ");
370                                    }
371                                } else {
372                                    skip.enable();
373                                    break;
374                                }
375                            }
376                            Chunk::LineBreak => res.push('\n'),
377                        }
378                    }
379
380                    if code_block {
381                        res.push_str("  ```\n");
382                        code_block = false;
383                    }
384                }
385                Token::BlockStart(b) => {
386                    change_to_markdown_style(&mut res, &mut cur_style, Styles::default());
387                    match b {
388                        Block::Header => {
389                            blank_markdown_line(&mut res);
390                            if app_name_seen {
391                                res.push_str("## ");
392                            } else {
393                                res.push_str("# ");
394                                app_name_seen = true;
395                            }
396                        }
397                        Block::Section2 => {
398                            res.push_str("");
399                        }
400                        Block::ItemTerm | Block::Section4 => {
401                            new_markdown_line(&mut res);
402                            empty_term = matches!(
403                                self.tokens.get(ix + 1),
404                                Some(Token::BlockEnd(Block::ItemTerm))
405                            );
406                            res.push_str(if empty_term { "  " } else { "- " });
407                        }
408                        Block::ItemBody => {
409                            if def_list {
410                                res.push_str(if empty_term { " " } else { " &mdash; " });
411                            }
412                            new_markdown_line(&mut res);
413                            res.push_str("  ");
414                        }
415                        Block::DefinitionList => {
416                            def_list = true;
417                            res.push_str("");
418                        }
419                        Block::Block => {
420                            res.push('\n');
421                        }
422                        Block::Meta => todo!(),
423                        Block::Mono => {
424                            mono += 1;
425                        }
426                        Block::Section3 => res.push_str("### "),
427                        Block::TermRef => {}
428                        Block::InlineBlock => {
429                            skip.push();
430                        }
431                    }
432                }
433                Token::BlockEnd(b) => {
434                    change_to_markdown_style(&mut res, &mut cur_style, Styles::default());
435                    match b {
436                        Block::Header
437                        | Block::Block
438                        | Block::Section3
439                        | Block::Section2
440                        | Block::Section4 => {
441                            res.push('\n');
442                        }
443
444                        Block::InlineBlock => {
445                            skip.pop();
446                        }
447                        Block::ItemTerm | Block::TermRef => {}
448                        Block::ItemBody => {
449                            if def_list {
450                                res.push('\n');
451                            }
452                        }
453                        Block::DefinitionList => {
454                            def_list = false;
455                            res.push('\n');
456                        }
457                        Block::Mono => {
458                            mono -= 1;
459                        }
460                        Block::Meta => todo!(),
461                    }
462                }
463            }
464        }
465        change_to_markdown_style(&mut res, &mut cur_style, Styles::default());
466        res
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn transitions_are_okay() {
476        let mut doc = Doc::default();
477
478        doc.emphasis("Usage: "); // bold
479        doc.literal("my_program"); // bold + tt
480
481        let r = doc.render_html(true, false);
482
483        assert_eq!(r, "<b>Usage: </b><tt><b>my_program</b></tt>")
484    }
485}