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 => res.push_str("<div style='padding-left: 0.5em'>"),
263                        Block::Mono | Block::TermRef => {}
264                        Block::InlineBlock => {
265                            skip.push();
266                        }
267                    }
268                    stack.push(b);
269                }
270                Token::BlockEnd(b) => {
271                    change_style(&mut res, &mut cur_style, Styles::default());
272                    stack.pop();
273                    match b {
274                        Block::Header => {
275                            blank_html_line(&mut res);
276                        }
277                        Block::Section2 => {
278                            res.push_str("</div>");
279                        }
280
281                        Block::InlineBlock => {
282                            skip.pop();
283                        }
284                        Block::ItemTerm => res.push_str("</dt>\n"),
285                        Block::ItemBody => {
286                            if stack.last().copied() == Some(Block::DefinitionList) {
287                                res.push_str("</dd>\n");
288                            } else {
289                                res.push_str("</li>\n");
290                            }
291                        }
292                        Block::DefinitionList => res.push_str("</dl>\n"),
293                        Block::Block => {
294                            res.push_str("</p>");
295                        }
296                        Block::Mono | Block::TermRef => {}
297                        Block::Section3 => res.push_str("</div>"),
298                        Block::Meta => todo!(),
299                    }
300                }
301            }
302        }
303        change_style(&mut res, &mut cur_style, Styles::default());
304        if include_css {
305            res.push_str(CSS);
306        }
307        res
308    }
309
310    /// Render doc into markdown document, used by documentation sample generator
311    #[must_use]
312    pub fn render_markdown(&self, full: bool) -> String {
313        let mut res = String::new();
314        let mut byte_pos = 0;
315        let mut cur_style = Styles::default();
316
317        let mut skip = Skip::default();
318        let mut empty_term = false;
319        let mut mono = 0;
320        let mut def_list = false;
321        let mut code_block = false;
322        let mut app_name_seen = false;
323        for (ix, token) in self.tokens.iter().copied().enumerate() {
324            match token {
325                Token::Text { bytes, style } => {
326                    let input = &self.payload[byte_pos..byte_pos + bytes];
327                    byte_pos += bytes;
328                    if skip.enabled() {
329                        continue;
330                    }
331
332                    change_to_markdown_style(&mut res, &mut cur_style, Styles::from(style));
333
334                    for chunk in split(input) {
335                        match chunk {
336                            Chunk::Raw(input, w) => {
337                                if w == Chunk::TICKED_CODE {
338                                    new_markdown_line(&mut res);
339                                    res.push_str("  ");
340                                    res.push_str(input);
341                                    res.push('\n');
342                                } else if w == Chunk::CODE {
343                                    if !code_block {
344                                        res.push_str("\n\n  ```text\n");
345                                    }
346                                    code_block = true;
347                                    res.push_str("  ");
348                                    res.push_str(input);
349                                    res.push('\n');
350                                } else {
351                                    if code_block {
352                                        res.push_str("\n  ```\n");
353                                        code_block = false;
354                                    }
355                                    if mono > 0 {
356                                        let input = input.replace('[', "\\[").replace(']', "\\]");
357                                        res.push_str(&input);
358                                    } else {
359                                        res.push_str(input);
360                                    }
361                                }
362                            }
363                            Chunk::Paragraph => {
364                                if full {
365                                    res.push_str("\n\n");
366                                    if def_list {
367                                        res.push_str("  ");
368                                    }
369                                } else {
370                                    skip.enable();
371                                    break;
372                                }
373                            }
374                            Chunk::LineBreak => res.push('\n'),
375                        }
376                    }
377
378                    if code_block {
379                        res.push_str("  ```\n");
380                        code_block = false;
381                    }
382                }
383                Token::BlockStart(b) => {
384                    change_to_markdown_style(&mut res, &mut cur_style, Styles::default());
385                    match b {
386                        Block::Header => {
387                            blank_markdown_line(&mut res);
388                            if app_name_seen {
389                                res.push_str("## ");
390                            } else {
391                                res.push_str("# ");
392                                app_name_seen = true;
393                            }
394                        }
395                        Block::Section2 => {
396                            res.push_str("");
397                        }
398                        Block::ItemTerm => {
399                            new_markdown_line(&mut res);
400                            empty_term = matches!(
401                                self.tokens.get(ix + 1),
402                                Some(Token::BlockEnd(Block::ItemTerm))
403                            );
404                            res.push_str(if empty_term { "  " } else { "- " });
405                        }
406                        Block::ItemBody => {
407                            if def_list {
408                                res.push_str(if empty_term { " " } else { " &mdash; " });
409                            }
410                            new_markdown_line(&mut res);
411                            res.push_str("  ");
412                        }
413                        Block::DefinitionList => {
414                            def_list = true;
415                            res.push_str("");
416                        }
417                        Block::Block => {
418                            res.push('\n');
419                        }
420                        Block::Meta => todo!(),
421                        Block::Mono => {
422                            mono += 1;
423                        }
424                        Block::Section3 => res.push_str("### "),
425                        Block::TermRef => {}
426                        Block::InlineBlock => {
427                            skip.push();
428                        }
429                    }
430                }
431                Token::BlockEnd(b) => {
432                    change_to_markdown_style(&mut res, &mut cur_style, Styles::default());
433                    match b {
434                        Block::Header | Block::Block | Block::Section3 | Block::Section2 => {
435                            res.push('\n');
436                        }
437
438                        Block::InlineBlock => {
439                            skip.pop();
440                        }
441                        Block::ItemTerm | Block::TermRef => {}
442                        Block::ItemBody => {
443                            if def_list {
444                                res.push('\n');
445                            }
446                        }
447                        Block::DefinitionList => {
448                            def_list = false;
449                            res.push('\n');
450                        }
451                        Block::Mono => {
452                            mono -= 1;
453                        }
454                        Block::Meta => todo!(),
455                    }
456                }
457            }
458        }
459        change_to_markdown_style(&mut res, &mut cur_style, Styles::default());
460        res
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn transitions_are_okay() {
470        let mut doc = Doc::default();
471
472        doc.emphasis("Usage: "); // bold
473        doc.literal("my_program"); // bold + tt
474
475        let r = doc.render_html(true, false);
476
477        assert_eq!(r, "<b>Usage: </b><tt><b>my_program</b></tt>")
478    }
479}