Skip to main content

logicaffeine_cli/browser/
bundle.rs

1//! What a browser loads: a document, the page's WebAssembly module, and the host runtime.
2
3use std::collections::BTreeMap;
4
5use logicaffeine_compile::page_host::PAGE_STEM as STEM;
6use logicaffeine_compile::vm::reach::HostEntries;
7
8/// A built page: files by URL path, and the path the browser opens.
9#[derive(Clone, Debug)]
10pub struct PageBundle {
11    files: BTreeMap<String, (String, Vec<u8>)>,
12    entry: String,
13}
14
15impl PageBundle {
16    /// A bundle holding one document, served as HTML at `entry`.
17    pub fn new(entry: &str, document: impl Into<Vec<u8>>) -> PageBundle {
18        let mut files = BTreeMap::new();
19        files.insert(
20            entry.to_string(),
21            ("text/html; charset=utf-8".to_string(), document.into()),
22        );
23        PageBundle {
24            files,
25            entry: entry.to_string(),
26        }
27    }
28
29    /// Add a file, typed by its extension through the table `largo serve` uses.
30    pub fn with_file(mut self, path: &str, bytes: impl Into<Vec<u8>>) -> PageBundle {
31        self.files.insert(
32            path.to_string(),
33            (crate::content_type_for(path).to_string(), bytes.into()),
34        );
35        self
36    }
37
38    /// The document with `markup` placed at the end of its head, as `largo serve` places a
39    /// project's stylesheet links.
40    pub fn with_head_markup(mut self, markup: &str) -> PageBundle {
41        if let Some((_, doc)) = self.files.get_mut(&self.entry) {
42            let text =
43                String::from_utf8_lossy(doc).replacen("</head>", &format!("{markup}</head>"), 1);
44            *doc = text.into_bytes();
45        }
46        self
47    }
48
49    /// Serve the document at `path` instead, as a page declared at that path would be — and the
50    /// module and host beside it, where the document's relative `./app.js` and the host's
51    /// `./app.wasm` resolve from that path.
52    pub fn at(mut self, path: &str) -> PageBundle {
53        let dir = &path[..=path.rfind('/').unwrap_or(0)];
54        for file in [format!("{STEM}.wasm"), format!("{STEM}.js")] {
55            if let Some(bytes) = self.files.remove(&format!("/{file}")) {
56                self.files.insert(format!("{dir}{file}"), bytes);
57            }
58        }
59        if let Some(doc) = self.files.remove(&self.entry) {
60            self.files.insert(path.to_string(), doc);
61        }
62        self.entry = path.to_string();
63        self
64    }
65
66    /// A page as `largo emit wasm-dom` ships it: the module, the browser host, and the document
67    /// shell with nothing pre-rendered — the first paint is the module's.
68    pub fn page(source: &str) -> Result<PageBundle, String> {
69        Self::page_with(source, &HostEntries::default())
70    }
71
72    /// [`PageBundle::page`] for a host that also mounts `entries`' components on demand: the
73    /// module exports each one's mount, which `runtime.js`'s `__logosMount` calls. The document holds
74    /// no pre-rendered markup, so the module draws fresh and carries no hydration.
75    pub fn page_with(source: &str, entries: &HostEntries) -> Result<PageBundle, String> {
76        let shell = logicaffeine_compile::web_host::browser_host_html(STEM);
77        Self::with_module(source, shell, &entries.clone().drawing_fresh())
78    }
79
80    /// A page as `largo build` ships it: the same module and host, with the page's markup already
81    /// in the document body, rendered by the build's own pre-render, and served at the URL its
82    /// `## Page` declares, where the build puts it. A path with a `:segment` has no one URL to
83    /// serve, so that page stays at `/`.
84    pub fn prerendered(source: &str) -> Result<PageBundle, String> {
85        if let Some(why) = crate::commands::web_bundle::prerender_refusal(source) {
86            return Err(format!("the page cannot be pre-rendered: {why}"));
87        }
88        let doc = crate::prerender_page_html(source, STEM);
89        let bundle = Self::with_module(source, doc, &HostEntries::default())?;
90        Ok(match declared_page_path(source) {
91            Some(path) if path != "/" && !path.contains(':') => bundle.at(&path),
92            _ => bundle,
93        })
94    }
95
96    fn with_module(
97        source: &str,
98        document: String,
99        entries: &HostEntries,
100    ) -> Result<PageBundle, String> {
101        let wasm =
102            logicaffeine_compile::compile::compile_to_wasm_with(source, entries).map_err(|e| {
103                let d = e.diagnostic_parts(source);
104                format!(
105                    "the page does not compile to WebAssembly (line {}): {}",
106                    d.line_num, d.message
107                )
108            })?;
109        Ok(PageBundle::new("/", document)
110            .with_file(&format!("/{STEM}.wasm"), wasm)
111            .with_file(
112                &format!("/{STEM}.js"),
113                logicaffeine_compile::web_host::browser_host_js(STEM),
114            ))
115    }
116
117    /// The path the browser opens.
118    pub fn entry(&self) -> &str {
119        &self.entry
120    }
121
122    /// The bytes served at `path`, if the bundle holds it.
123    pub fn file(&self, path: &str) -> Option<&[u8]> {
124        self.files.get(path).map(|(_, b)| b.as_slice())
125    }
126
127    pub(crate) fn paths(&self) -> std::collections::HashSet<String> {
128        self.files.keys().cloned().collect()
129    }
130
131    pub(crate) fn served(&self) -> BTreeMap<String, (String, Vec<u8>)> {
132        self.files.clone()
133    }
134}
135
136/// The URL the `## Page` in `source` declares, if the program auto-mounts one.
137fn declared_page_path(source: &str) -> Option<String> {
138    logicaffeine_compile::ui_bridge::with_parsed_program(source, |parsed, interner| {
139        parsed.ok().and_then(|(stmts, ..)| {
140            logicaffeine_compile::page_host::auto_mounted_page_path(stmts, interner)
141        })
142    })
143}