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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
use crate::error_pages::ErrorPageData;
use crate::page_data::PageData;
use std::collections::HashMap;
use std::{env, fmt};

/// Escapes special characters in page data that might interfere with JavaScript
/// processing.
fn escape_page_data(data: &str) -> String {
    data.to_string()
        // We escape any backslashes to prevent their interfering with JSON delimiters
        .replace('\\', r#"\\"#)
        // We escape any backticks, which would interfere with JS's raw strings system
        .replace('`', r#"\`"#)
        // We escape any interpolations into JS's raw string system
        .replace(r#"${"#, r#"\${"#)
}

/// The shell used to interpolate the Perseus app into, including associated
/// scripts and content defined by the user, components of the Perseus core, and
/// plugins.
#[derive(Clone, Debug)]
pub struct HtmlShell {
    /// The actual shell content, on whcih interpolations will be performed.
    pub shell: String,
    /// Additional contents of the head before the interpolation boundary.
    pub head_before_boundary: Vec<String>,
    /// Scripts to be inserted before the interpolation boundary.
    pub scripts_before_boundary: Vec<String>,
    /// Additional contents of the head after the interpolation boundary. These
    /// will be wiped out after a page transition.
    pub head_after_boundary: Vec<String>,
    /// Scripts to be interpolated after the interpolation bounary. These will
    /// be wiped out after a page transition.
    pub scripts_after_boundary: Vec<String>,
    /// Content to be interpolated into the body of the shell.
    pub content: String,
    /// Code to be inserted into the shell before the Perseus contents of the
    /// page. This is designed to be modified by plugins.
    pub before_content: Vec<String>,
    /// Code to be inserted into the shell after the Perseus contents of the
    /// page. This is designed to be modified by plugins.
    pub after_content: Vec<String>,
    /// The ID of the element into which we'll interpolate content.
    root_id: String,
    /// The path prefix to use.
    #[cfg_attr(not(feature = "preload-wasm-on-redirect"), allow(dead_code))]
    path_prefix: String,
}
impl HtmlShell {
    /// Initializes the HTML shell by interpolating necessary scripts into it
    /// and adding the render configuration.
    pub fn new(
        shell: String,
        root_id: &str,
        render_cfg: &HashMap<String, String>,
        path_prefix: &str,
    ) -> Self {
        let mut head_before_boundary = Vec::new();
        let mut scripts_before_boundary = Vec::new();

        // Define the render config as a global variable
        let render_cfg = format!(
            "window.__PERSEUS_RENDER_CFG = '{render_cfg}';",
            // It's safe to assume that something we just deserialized will serialize again in this
            // case
            render_cfg = serde_json::to_string(render_cfg).unwrap()
        );
        scripts_before_boundary.push(render_cfg);

        // Inject a global variable to identify whether we are testing (picked up by app
        // shell to trigger helper DOM events)
        if env::var("PERSEUS_TESTING").is_ok() {
            scripts_before_boundary.push("window.__PERSEUS_TESTING = true;".into());
        }

        // Define the script that will load the Wasm bundle (inlined to avoid
        // unnecessary extra requests) If we're using the `wasm2js` feature,
        // this will try to load a JS version instead (expected to be at
        // `/.perseus/bundle.wasm.js`)
        //
        // Note: because we're using binary bundles, we don't need to import
        // a `main` function or the like, `init()` just works
        #[cfg(not(feature = "wasm2js"))]
        let load_wasm_bundle = format!(
            r#"
        import init from "{path_prefix}/.perseus/bundle.js";
        async function main() {{
            await init("{path_prefix}/.perseus/bundle.wasm");
        }}
        main();
        "#,
            path_prefix = path_prefix
        );
        #[cfg(feature = "wasm2js")]
        let load_wasm_bundle = format!(
            r#"
        import init from "{path_prefix}/.perseus/bundle.js";
        async function main() {{
            await init("{path_prefix}/.perseus/bundle.wasm.js");
        }}
        main();
        "#,
            path_prefix = path_prefix
        );
        scripts_before_boundary.push(load_wasm_bundle);

        // If we're in development, pass through the host/port of the reload server if
        // we're using it We'll depend on the `PERSEUS_USE_RELOAD_SERVER`
        // environment variable here, which is set by the CLI's controller process, not
        // the user That way, we won't do this if the reload server doesn't
        // exist
        #[cfg(debug_assertions)]
        if env::var("PERSEUS_USE_RELOAD_SERVER").is_ok() {
            let host =
                env::var("PERSEUS_RELOAD_SERVER_HOST").unwrap_or_else(|_| "localhost".to_string());
            let port =
                env::var("PERSEUS_RELOAD_SERVER_PORT").unwrap_or_else(|_| "3100".to_string());
            scripts_before_boundary
                .push(format!("window.__PERSEUS_RELOAD_SERVER_HOST = '{}'", host));
            scripts_before_boundary
                .push(format!("window.__PERSEUS_RELOAD_SERVER_PORT = '{}'", port));
        }

        // Add in the `<base>` element at the very top so that it applies to everything
        // in the HTML shell Otherwise any stylesheets loaded before it won't
        // work properly We add a trailing `/` to the base URL (https://stackoverflow.com/a/26043021)
        // Note that it's already had any pre-existing ones stripped away
        let base = format!(r#"<base href="{}/" />"#, path_prefix);
        head_before_boundary.push(base);

        Self {
            shell,
            head_before_boundary,
            scripts_before_boundary,
            head_after_boundary: Vec::new(),
            scripts_after_boundary: Vec::new(),
            before_content: Vec::new(),
            after_content: Vec::new(),
            content: "".into(),
            root_id: root_id.into(),
            path_prefix: path_prefix.into(),
        }
    }

    /// Interpolates page data, global state, and translations into the shell.
    ///
    /// The translations provided should be the source string from which a
    /// translator can be derived on the client-side. These are provided in
    /// a window variable to avoid page interactivity requiring a network
    /// request to get them.
    pub fn page_data(
        mut self,
        page_data: &PageData,
        global_state: &Option<String>,
        translations: &str,
    ) -> Self {
        // Interpolate a global variable of the state so the app shell doesn't have to
        // make any more trips The app shell will unset this after usage so it
        // doesn't contaminate later non-initial loads Error pages (above) will
        // set this to `error`
        let initial_state = if let Some(state) = &page_data.state {
            escape_page_data(state)
        } else {
            "None".to_string()
        };
        let global_state = if let Some(state) = global_state {
            escape_page_data(state)
        } else {
            "None".to_string()
        };
        let translations = escape_page_data(translations);

        // We put this at the very end of the head (after the delimiter comment) because
        // it doesn't matter if it's expunged on subsequent loads
        let initial_state = format!("window.__PERSEUS_INITIAL_STATE = `{}`;", initial_state);
        self.scripts_after_boundary.push(initial_state);
        // But we'll need the global state as a variable until a template accesses it,
        // so we'll keep it around (even though it should actually instantiate validly
        // and not need this after the initial load)
        let global_state = format!("window.__PERSEUS_GLOBAL_STATE = `{}`;", global_state);
        self.scripts_before_boundary.push(global_state);
        // We can put the translations after the boundary, because we'll only need them
        // on the first page, and then they'll be automatically cached
        //
        // Note that we don't need to interpolate the locale, since that's trivially
        // known from the URL
        let translations = format!("window.__PERSEUS_TRANSLATIONS = `{}`;", translations);
        self.scripts_after_boundary.push(translations);
        // Interpolate the document `<head>` (this should of course be removed between
        // page loads)
        self.head_after_boundary.push((&page_data.head).into());
        // And set the content
        self.content = (&page_data.content).into();

        self
    }

    /// Interpolates a fallback for locale redirection pages such that, even if
    /// JavaScript is disabled, the user will still be redirected to the default
    /// locale. From there, Perseus' inbuilt progressive enhancement can
    /// occur, but without this a user directed to an unlocalized page with JS
    /// disabled would see a blank screen, which is terrible UX. Note that
    /// this also includes a fallback for if JS is enabled but Wasm is disabled.
    /// Note that the redirect URL is expected to be generated with a path
    /// prefix inbuilt.
    ///
    /// This also adds a `__perseus_initial_state` `<div>` in case it's needed
    /// (for Wasm redirections).
    ///
    /// Further, this will preload the Wasm binary, making redirection snappier
    /// (but initial load slower), a tradeoff that generally improves UX.
    pub fn locale_redirection_fallback(mut self, redirect_url: &str) -> Self {
        // This will be used if JavaScript is completely disabled (it's then the site's
        // responsibility to show a further message)
        let dumb_redirect = format!(
            r#"<noscript>
        <meta http-equiv="refresh" content="0; url={}" />
    </noscript>"#,
            redirect_url
        );

        // This will be used if JS is enabled, but Wasm is disabled or not supported
        // (it's then the site's responsibility to show a further message)
        //
        // Wasm support detector courtesy https://stackoverflow.com/a/47880734
        let js_redirect = format!(
            r#"
        function wasmSupported() {{
            try {{
                if (typeof WebAssembly === "object"
                    && typeof WebAssembly.instantiate === "function") {{
                    const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00));
                    if (module instanceof WebAssembly.Module) {{
                        return new WebAssembly.Instance(module) instanceof WebAssembly.Instance;
                    }}
                }}
            }} catch (e) {{}}
            return false;
        }}

        if (!wasmSupported()) {{
            window.location.replace("{}");
        }}
            "#,
            redirect_url
        );

        self.head_after_boundary.push(dumb_redirect);
        self.scripts_after_boundary.push(js_redirect);
        #[cfg(feature = "preload-wasm-on-redirect")]
        {
            // Interpolate a preload of the Wasm bundle
            // This forces the browser to get the bundle before loading the page, which
            // makes the time users spend on a blank screen much shorter We have
            // no leading `/` here because of the `<base>` interpolation
            // Note that this has to come before the code that actually loads the Wasm
            // bundle The aim of this is to make the time loading increase so
            // that the time blanking decreases
            let wasm_preload = format!(
                r#"<link rel="preload" href="{path_prefix}/.perseus/bundle.wasm" as="fetch" />"#,
                path_prefix = self.path_prefix
            );
            self.head_before_boundary.push(wasm_preload.into());
        }

        self
    }

    /// Interpolates page error data into the shell in the event of a failure.
    ///
    /// Importantly, this makes no assumptions about the availability of
    /// translations, so error pages rendered from here will not be
    /// internationalized.
    // TODO Provide translations where we can at least?
    pub fn error_page(
        mut self,
        error_page_data: &ErrorPageData,
        error_html: &str,
        error_head: &str,
    ) -> Self {
        let error = serde_json::to_string(error_page_data).unwrap();
        let state_var = format!(
            "window.__PERSEUS_INITIAL_STATE = `error-{}`;",
            escape_page_data(&error),
        );
        self.scripts_after_boundary.push(state_var);
        self.head_after_boundary.push(error_head.to_string());
        self.content = error_html.into();

        self
    }
}
// This code actually interpolates everything in the correct places
// Because of the way these string interpolations work, there MUST NOT be
// hydration IDs on the `<head>` or `<body>` tags, or Perseus will break in very
// unexpected ways
impl fmt::Display for HtmlShell {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let head_start = self.head_before_boundary.join("\n");
        // We also inject a delimiter dummy `<meta>` tag that will be used to wall off
        // the constant document head from the interpolated document head
        let head_end = format!(
            r#"
            <script type="module">{scripts_before_boundary}</script>
            <meta itemprop="__perseus_head_boundary" content="">
            {head_after_boundary}
            <script>{scripts_after_boundary}</script>
            "#,
            scripts_before_boundary = self.scripts_before_boundary.join("\n"),
            head_after_boundary = self.head_after_boundary.join("\n"),
            scripts_after_boundary = self.scripts_after_boundary.join("\n"),
        );

        let shell_with_head = self
            .shell
            .replace("<head>", &format!("<head>{}", head_start))
            .replace("</head>", &format!("{}</head>", head_end));

        let body_start = self.before_content.join("\n");
        let body_end = self.after_content.join("\n");
        let shell_with_body = shell_with_head
            .replace("<body>", &format!("<body>{}", body_start))
            .replace("</body>", &format!("{}</body>", body_end));

        // The user MUST place have a `<div>` of this exact form (documented explicitly)
        // We permit either double or single quotes
        let html_to_replace_double = format!("<div id=\"{}\">", self.root_id);
        let html_to_replace_single = format!("<div id='{}'>", self.root_id);
        let html_replacement = format!(
            // We give the content a specific ID so that it can be deleted if an error page needs
            // to be rendered on the client-side
            "{}{}",
            &html_to_replace_double, self.content,
        );
        // Now interpolate that HTML into the HTML shell
        let new_shell = shell_with_body
            .replace(&html_to_replace_double, &html_replacement)
            .replace(&html_to_replace_single, &html_replacement);

        f.write_str(&new_shell)
    }
}