markrs/
html_generator.rs

1//! This module provides functionality to generate HTML from markdown block elements.
2
3use ammonia::clean;
4
5use crate::CONFIG;
6use crate::config::Config;
7use crate::types::{MdBlockElement, ToHtml};
8use crate::utils::build_rel_prefix;
9
10/// Generates an HTML string from a vector of MdBlockElements
11///
12/// # Arguments
13/// * `file_name` - The name of the markdown file, used to set the title of the HTML document.
14/// * `md_elements` - A vector of `MdBlockElement` instances representing the markdown content.
15/// * `output_dir` - The directory where the generated HTML file will be saved.
16/// * `input_dir` - The directory where the markdown files are located, used for relative paths.
17/// * `html_rel_path` - The relative path to the HTML file from the output directory, used for
18///   linking resources.
19///
20/// # Returns
21/// Returns a `String` containing the generated HTML.
22pub fn generate_html(
23    file_name: &str,
24    md_elements: &[MdBlockElement],
25    output_dir: &str,
26    input_dir: &str,
27    html_rel_path: &str,
28) -> String {
29    let mut html_output = String::new();
30    let config = CONFIG.get().unwrap();
31
32    let head = generate_head(file_name, html_rel_path, config);
33
34    let mut body = String::from("\t<body>\n");
35    body.push_str(&indent_html(&generate_navbar(html_rel_path), 2));
36    body.push_str("\n\t\t<div id=\"content\">");
37
38    let inner_html: String = md_elements
39        .iter()
40        .map(|element| element.to_html(output_dir, input_dir, html_rel_path))
41        .collect::<Vec<String>>()
42        .join("\n");
43
44    let inner_html = if config.html.sanitize_html {
45        let mut builder = ammonia::Builder::default();
46        builder
47            .add_tag_attributes("a", &["href", "title", "target"])
48            .add_tag_attribute_values("a", "target", &["_blank", "_self"])
49            .add_tag_attributes("pre", &["class"])
50            .add_tag_attributes("code", &["class"])
51            .add_tags(&["iframe"])
52            .add_tag_attributes(
53                "iframe",
54                &[
55                    "src",
56                    "width",
57                    "height",
58                    "title",
59                    "frameborder",
60                    "allowfullscreen",
61                ],
62            );
63        for tag in &["h1", "h2", "h3", "h4", "h5", "h6"] {
64            builder.add_tag_attributes(tag, &["id"]);
65        }
66
67        builder.clean(&inner_html).to_string()
68    } else {
69        inner_html
70    };
71
72    body.push_str(&indent_html(&inner_html, 3));
73    body.push_str("\n\t\t</div>");
74
75    if config.html.use_prism {
76        body.push_str(
77            "\n\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-core.min.js\" integrity=\"sha512-Uw06iFFf9hwoN77+kPl/1DZL66tKsvZg6EWm7n6QxInyptVuycfrO52hATXDRozk7KWeXnrSueiglILct8IkkA==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>",
78        );
79        body.push_str("\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/plugins/line-numbers/prism-line-numbers.min.js\" integrity=\"sha512-BttltKXFyWnGZQcRWj6osIg7lbizJchuAMotOkdLxHxwt/Hyo+cl47bZU0QADg+Qt5DJwni3SbYGXeGMB5cBcw==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>");
80        body.push_str(
81            "\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/plugins/autoloader/prism-autoloader.min.js\" integrity=\"sha512-SkmBfuA2hqjzEVpmnMt/LINrjop3GKWqsuLSSB3e7iBmYK7JuWw4ldmmxwD9mdm2IRTTi0OxSAfEGvgEi0i2Kw==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>"
82        );
83        body.push_str("\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/plugins/toolbar/prism-toolbar.min.js\" integrity=\"sha512-st608h+ZqzliahyzEpETxzU0f7z7a9acN6AFvYmHvpFhmcFuKT8a22TT5TpKpjDa3pt3Wv7Z3SdQBCBdDPhyWA==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>");
84        body.push_str("\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js\" integrity=\"sha512-/kVH1uXuObC0iYgxxCKY41JdWOkKOxorFVmip+YVifKsJ4Au/87EisD1wty7vxN2kAhnWh6Yc8o/dSAXj6Oz7A==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>");
85        body.push_str("\n\t\t<script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/plugins/show-language/prism-show-language.min.js\" integrity=\"sha512-d1t+YumgzdIHUL78me4B9NzNTu9Lcj6RdGVbdiFDlxRV9JTN9s+iBQRhUqLRq5xtWUp1AD+cW2sN2OlST716fw==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>");
86    }
87
88    body.push_str("\n\t</body>\n");
89
90    html_output.push_str(&head);
91    html_output.push_str(&body);
92    html_output.push_str("</html>\n");
93
94    html_output
95}
96
97/// Generates the index HTML file that lists all pages
98///
99/// # Arguments
100/// * `file_names` - A slice of `String` containing the names of the markdown files.
101///
102/// # Returns
103/// Returns a `String` containing the generated HTML for the index page.
104pub fn generate_index(file_names: &[String]) -> String {
105    let mut html_output = String::new();
106
107    let head = generate_head("index", "index.html", CONFIG.get().unwrap());
108
109    let mut body = String::from("\t<body>\n");
110    body.push_str(&generate_navbar("index.html"));
111    body.push_str("\n\t<div id=\"content\">\n");
112    body.push_str("<h1>All Pages</h1>\n");
113
114    file_names.iter().for_each(|file_name| {
115        body.push_str(&format!(
116            "<a href=\"./{}.html\">{}</a><br>\n",
117            file_name.trim_end_matches(".md"),
118            format_title(file_name)
119        ));
120    });
121
122    body.push_str("\n</div>\n\t</body>\n");
123
124    html_output.push_str(&head);
125    html_output.push_str(&body);
126    html_output.push_str("</html>\n");
127
128    html_output
129}
130
131/// Generates the HTML head section
132///
133/// # Arguments
134/// * `file_name` - The name of the markdown file, used to set the title of the HTML document.
135/// * `html_rel_path` - The relative path to the HTML file from the output directory, used for
136///   linking
137fn generate_head(file_name: &str, html_rel_path: &str, config: &Config) -> String {
138    let mut head = String::from(
139        r#"<!DOCTYPE html>
140    <html lang="en">
141    <head>
142        <meta charset="UTF-8">
143        <meta name="viewport" content="width=device-width, initial-scale=1.0">
144    "#,
145    );
146
147    // Remove the file extension from the file name and make it title case
148    let title = format_title(file_name);
149    head.push_str(&format!("\t<title>{}</title>\n", title));
150
151    let favicon_file = &config.html.favicon_file;
152    if !favicon_file.is_empty() {
153        let mut favicon_path = build_rel_prefix(html_rel_path);
154        favicon_path.push("media");
155        favicon_path.push(favicon_file.rsplit("/").next().unwrap());
156        let favicon_href = favicon_path.to_string_lossy();
157
158        head.push_str(&format!(
159            "\t<link rel=\"icon\" href=\"{}\">\n",
160            favicon_href
161        ));
162    }
163
164    let css_file = &config.html.css_file;
165    let mut css_path = build_rel_prefix(html_rel_path);
166    css_path.push("styles.css");
167    let css_href = css_path.to_string_lossy();
168
169    if css_file == "default" {
170        head.push_str(&format!(
171            "\t\t<link rel=\"stylesheet\" href=\"{}\">\n",
172            css_href
173        ));
174    } else {
175        head.push_str(&format!(
176            "\t\t<link rel=\"stylesheet\" href=\"{}\">\n",
177            css_file
178        ));
179    }
180
181    if config.html.use_prism {
182        if !config.html.prism_theme.is_empty() {
183            let theme = if config.html.sanitize_html {
184                &clean(&config.html.prism_theme)
185            } else {
186                &config.html.prism_theme
187            };
188
189            head.push_str(&format!("\t\t<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/prism-themes/1.9.0/prism-{}.min.css\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\" />", theme));
190        } else {
191            head.push_str("\t\t<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/prismjs@1.30.0/themes/prism-okaidia.min.css\">");
192        }
193        head.push_str("\t\t<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/plugins/toolbar/prism-toolbar.min.css\" integrity=\"sha512-Dqf5696xtofgH089BgZJo2lSWTvev4GFo+gA2o4GullFY65rzQVQLQVlzLvYwTo0Bb2Gpb6IqwxYWtoMonfdhQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\" />");
194        head.push_str("\t\t<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/plugins/line-numbers/prism-line-numbers.min.css\" integrity=\"sha512-cbQXwDFK7lj2Fqfkuxbo5iD1dSbLlJGXGpfTDqbggqjHJeyzx88I3rfwjS38WJag/ihH7lzuGlGHpDBymLirZQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\" />");
195    }
196
197    head.push_str("\t</head>\n");
198    head
199}
200
201/// Generates the HTML for the navigation bar
202fn generate_navbar(html_rel_path: &str) -> String {
203    let mut navbar = String::from("<header>\n\t<nav>\n\t\t<ul>\n");
204
205    let mut home_path = build_rel_prefix(html_rel_path);
206    home_path.push("index.html");
207    let home_href = home_path.to_string_lossy();
208
209    navbar.push_str(&format!(
210        "\t\t\t<li><a href=\"{}\">Home</a></li>",
211        home_href
212    ));
213    navbar.push_str("\n\t\t</ul>\n\t</nav>\n</header>\n\n");
214    navbar
215}
216/// Formats the file name to create a title for the HTML document
217///
218/// # Arguments
219/// * `file_name` - The name of the file, typically ending with `.md`.
220///
221/// # Returns
222/// The formatted title (i.e. "my_test_page.md" -> "My Test Page")
223fn format_title(file_name: &str) -> String {
224    let title = file_name.trim_end_matches(".md").replace('_', " ");
225
226    title
227        .split_whitespace()
228        .map(|word| {
229            let mut chars = word.chars();
230            match chars.next() {
231                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
232                None => String::new(),
233            }
234        })
235        .collect::<Vec<String>>()
236        .join(" ")
237}
238
239/// Indents each line of the given HTML string by the specified number of tabs.
240pub fn indent_html(html: &str, level: usize) -> String {
241    let indent = "\t".repeat(level);
242    html.lines()
243        .map(|line| {
244            let first_non_whitespace_token = line.chars().find(|c| !c.is_whitespace());
245
246            match first_non_whitespace_token {
247                Some('<') => format!("{indent}{line}"),
248                Some(_) => line.to_string(),
249                None => line.to_string(), // If the line is empty or only whitespace, return it unchanged
250            }
251        })
252        .collect::<Vec<_>>()
253        .join("\n")
254}
255
256/// Generates a default CSS stylesheet as a string.
257pub fn generate_default_css() -> String {
258    r#"
259    body {
260    background-color: #121212;
261    color: #e0e0e0;
262    font-family:
263        -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
264        Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
265    line-height: 1.75;
266    margin: 0;
267    padding: 0;
268    }
269
270    /* Card-like container for the page content */
271    #content {
272    background-color: #1e1e1e;
273    max-width: 780px;
274    margin: 1.5rem auto;
275    padding: 2rem;
276    border-radius: 12px;
277    box-shadow: 0 0 0 1px #2c2c2c;
278    }
279
280    header {
281    background-color: #1a1a1a;
282    border-bottom: 1px solid #333;
283    position: sticky;
284    top: 0;
285    z-index: 1000;
286    }
287
288    nav {
289    padding: 1rem 2rem;
290    display: flex;
291    justify-content: flex-start;
292    }
293
294    nav ul {
295    list-style: none;
296    margin: 0;
297    padding: 0;
298    display: flex;
299    gap: 1rem;
300    }
301
302    nav ul li {
303    margin: 0;
304    }
305
306    nav ul li a {
307    color: #ddd;
308    text-decoration: none;
309    padding: 0.5rem 1rem;
310    border-radius: 6px;
311    transition: background-color 0.2s ease, color 0.2s ease;
312    }
313
314    nav ul li a:hover {
315    background-color: #2f2f2f;
316    color: #fff;
317    }
318
319    nav ul li a.active {
320    background-color: #4ea1f3;
321    color: #121212;
322    }
323    h1,
324    h2,
325    h3,
326    h4,
327    h5,
328    h6 {
329    color: #ffffff;
330    line-height: 1.3;
331    margin-top: 2rem;
332    margin-bottom: 1rem;
333    }
334
335    h1 {
336    font-size: 2.25rem;
337    border-bottom: 2px solid #2c2c2c;
338    padding-bottom: 0.3rem;
339    }
340    h2 {
341    font-size: 1.75rem;
342    border-bottom: 1px solid #2c2c2c;
343    padding-bottom: 0.2rem;
344    }
345    h3 {
346    font-size: 1.5rem;
347    }
348    h4 {
349    font-size: 1.25rem;
350    }
351    h5,
352    h6 {
353    font-size: 1rem;
354    font-weight: normal;
355    }
356
357    p {
358    margin-bottom: 1.2rem;
359    }
360
361    a {
362    color: #4ea1f3;
363    text-decoration: none;
364    transition: color 0.2s ease-in-out;
365    }
366    a:hover {
367    color: #82cfff;
368    text-decoration: underline;
369    }
370
371    img {
372    max-width: 100%;
373    height: auto;
374    display: block;
375    margin: 1.5rem auto;
376    border-radius: 8px;
377    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
378    }
379
380    /* Styles for when "use_prism = false" is set in config.toml */
381    pre.non_prism {
382    background-color: #2a2a2a;
383    padding: 1rem;
384    border-radius: 8px;
385    overflow-x: auto;
386    font-size: 0.9rem;
387    box-shadow: inset 0 0 0 1px #333;
388    }
389    pre.non_prism::before {
390    counter-reset: listing;
391    }
392    code.non_prism {
393    font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
394    font-style: normal;
395    background-color: #2a2a2a;
396    padding: 0.2em 0.4em;
397    border-radius: 4px;
398    font-size: 0.95em;
399    color: #dcdcdc;
400    }
401    pre.non_prism code.non_prism {
402    counter-increment: listing;
403    padding: 0 0.4em;
404    text-align: left;
405    float: left;
406    clear: left;
407    }
408    pre.non_prism code.non_prism::before {
409    content: counter(listing) ". ";
410    display: inline-block;
411    font-size: 0.85em;
412    float: left;
413    height: 1em;
414    padding-top: 0.2em;
415    padding-left: auto;
416    margin-left: auto;
417    text-align: right;
418    }
419
420    code {
421    font-style: normal;
422    }
423
424    blockquote {
425    border-left: 4px solid #555;
426    padding: 0.1rem 1rem;
427    color: #aaa;
428    font-style: italic;
429    margin: 1.5rem 0;
430    background-color: #1a1a1a;
431    border-radius: 2px;
432    }
433
434    .toolbar-item {
435    font-style: normal;
436    margin-right: 0.2em;
437    }
438
439    ul,
440    ol {
441    padding-left: 1.5rem;
442    margin-bottom: 1.2rem;
443    }
444    li {
445    margin-bottom: 0.5rem;
446    }
447
448    table {
449    width: 100%;
450    border-spacing: 0;
451    margin: 2rem 0;
452    background-color: #1e1e1e;
453    border: 1px solid #333;
454    border-radius: 8px;
455    overflow: hidden;
456    font-size: 0.95rem;
457    }
458
459    th,
460    td {
461    padding: 0.75rem 1rem;
462    text-align: left;
463    }
464
465    th {
466    background-color: #2a2a2a;
467    color: #ffffff;
468    font-weight: 600;
469    }
470
471    tr:nth-child(even) td {
472    background-color: #222;
473    }
474
475    tr:hover td {
476    background-color: #2f2f2f;
477    }
478
479    td {
480    color: #ddd;
481    border-top: 1px solid #333;
482    }
483
484    hr {
485    border: none;
486    border-top: 1px solid #333;
487    margin: 2rem 0;
488    }
489    "#
490    .to_string()
491}