1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
//! Management of external assets like Javascript and CSS.
//!
//! The files are stored into the binary as strings and written to the filesystem when the
//! application runs. For the HTML file, this allows the webview to load a URL instead of a string
//! body, which makes reloading smoother (update the file, refresh).
//!
//! For the other assets, it means the HTML can refer to local files instead of embedding the
//! contents as `<script>` and `<style>` tags, making the output easier to read and debug.

use std::collections::HashMap;
use std::fmt::Write;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::rc::Rc;

use anyhow::anyhow;
use log::{debug, warn};
use serde::{Serialize, Deserialize};
use tempfile::{tempdir, TempDir};

use crate::input::Config;
use crate::markdown::RenderedContent;

const MAIN_JS:    &str = include_str!("../res/js/main.js");
const MAIN_CSS:   &str = include_str!("../res/style/main.css");
const GITHUB_CSS: &str = include_str!("../res/style/github.css");

/// The version of highlight.js the app uses for code highlighting.
///
/// More details about the tool at https://highlightjs.org/
///
pub const HIGHLIGHT_JS_VERSION: &str = "9.18.1";

/// The client-side state of the page as the user's interacted with it. Currently, includes the
/// scroll position and the dimensions of images on the page, so that reloading doesn't change the
/// viewport.
///
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PageState {
    /// Scroll position of the page.
    pub scroll_top: f64,

    /// A cache of all the widths of images in the page, keyed by their URLs.
    pub image_widths: HashMap<String, f64>,

    /// A cache of all the heights of images in the page, keyed by their URLs.
    pub image_heights: HashMap<String, f64>,
}

/// A container for static assets.
///
/// Builds everything in either the explicitly-given directory, or a temporary one. Internally
/// reference-counted, so clones share the same storage.
///
#[derive(Debug, Clone)]
pub struct Assets {
    real_dir: Option<PathBuf>,
    temp_dir: Option<Rc<TempDir>>,
}

impl Assets {
    /// Create a new instance. It should never be necessary to create more than one, but it's
    /// possible.
    ///
    /// If the optional `output_dir` parameter is not given, the instance will use a temporary
    /// directory.
    ///
    /// If `output_dir` doesn't exist, it will be recursively created.
    ///
    pub fn init(output_dir: Option<PathBuf>) -> Result<Self, io::Error> {
        let assets =
            if let Some(real_dir) = output_dir {
                if !real_dir.is_dir() {
                    fs::create_dir_all(&real_dir)?;
                }

                let real_dir = Some(real_dir.canonicalize()?);
                Assets { real_dir, temp_dir: None }
            } else {
                let temp_dir = tempdir()?;
                let temp_dir = Some(Rc::new(temp_dir));
                Assets { temp_dir, real_dir: None }
            };
        // [Unwrap] We just constructed it, so an output path should exist:
        let output_path = assets.output_path().unwrap();

        fs::write(output_path.join("main.js"), MAIN_JS).
            unwrap_or_else(|e| warn!("{}", e));
        fs::write(output_path.join("main.css"), MAIN_CSS).
            unwrap_or_else(|e| warn!("{}", e));
        fs::write(output_path.join("github.css"), GITHUB_CSS).
            unwrap_or_else(|e| warn!("{}", e));

        Ok(assets)
    }

    /// Given an HTML fragment, wrap it up in whatever is necessary to turn it into a proper
    /// preview page and write it to a file.
    ///
    /// Input:
    ///
    /// - `content`:    The rendered HTML to write to a file, with some additional metadata.
    /// - `page_state`: Client-side data to embed in the document, so it can read it via javascript
    ///                 and maintain continuity with the previous load.
    ///
    /// Returns the path to the generated HTML file, or an error.
    ///
    pub fn build(&self, content: &RenderedContent, page_state: &PageState) -> anyhow::Result<PathBuf> {
        let output_path     = self.output_path()?;
        let custom_css_path = Config::css_path();

        let json_state = serde_json::to_string(page_state).
            unwrap_or_else(|e| {
                warn!("Couldn't build JSON state from {:?}: {:?}", page_state, e);
                String::from("{}")
            });

        let mut hl_tags = String::new();
        if !content.code_languages.is_empty() {
            let root_url = format!(
                "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/{}",
                HIGHLIGHT_JS_VERSION
            );

            // [Unwrap] Writing to a String should not fail
            writeln!(hl_tags, r#"<link rel="stylesheet" href="{}/styles/github.min.css" />"#, root_url).
                unwrap();
            writeln!(hl_tags, r#"<script src="{}/highlight.min.js"></script>"#, root_url).
                unwrap();

            for language in &content.code_languages {
                writeln!(
                    hl_tags,
                    r#"<script src="{}/languages/{}.min.js"></script>"#,
                    root_url, language
                ).unwrap();
            }

            writeln!(hl_tags, r#"<script>hljs.initHighlighting()</script>"#).unwrap();
        }

        debug!("Building HTML:");
        debug!(" > custom_css_path = {:?}", custom_css_path);
        debug!(" > page_state      = {:?}", json_state);
        debug!(" > code languages  = {:?}", content.code_languages);

        let page = format! {
            include_str!("../res/layout.html"),
            custom_css_path = custom_css_path.display(),
            body            = content.html,
            hl_tags         = hl_tags,
            page_state      = json_state,
        };

        let html_path = output_path.join("index.html");
        fs::write(&html_path, page.as_bytes())?;

        Ok(html_path)
    }

    /// The path on the filesystem where the HTML and other assets go. Could be a temporary
    /// directory, or the one given at construction time.
    ///
    pub fn output_path(&self) -> anyhow::Result<PathBuf> {
        match (&self.real_dir, &self.temp_dir) {
            (Some(path_buf), _) => Ok(path_buf.clone()),
            (_, Some(temp_dir)) => Ok(temp_dir.path().to_path_buf()),
            _ => Err(anyhow!("Assets don't have an output dir, there might be a synchronization error"))
        }
    }

    /// Delete the temporary directory used for building assets, if there is one. This should
    /// happen automatically on drop, but a GTK-level exit doesn't seem to unroll the stack, so we
    /// may need to delete things explicitly.
    ///
    /// If deletion fails, we quietly print a warning. Multiple (successful or failed) deletions
    /// are a noop.
    ///
    pub fn clean_up(&mut self) {
        if let Some(temp_dir) = self.temp_dir.take() {
            let path = temp_dir.path();
            fs::remove_dir_all(path).unwrap_or_else(|_| {
                warn!("Couldn't delete temporary dir: {}", path.display());
            });
        }
    }
}