quokka_templating/
scripting.rs

1use std::{
2    collections::{HashMap, HashSet},
3    marker::PhantomData,
4    sync::Arc,
5};
6
7use quokka_config::{Config, TryFromConfig};
8
9use crate::Result;
10
11use quokka_state::FromState;
12
13const DEFAULT_SCRIPTS_GROUP: &str = "default";
14
15///
16/// With this you register your script sources and global/"merged" scripts for the whole page
17///
18/// You usually only use it during bundle initialization. There are already handlers for `/scripting/path_to_your_script.js` and `/scripting/script.js`.
19///
20#[derive(Clone)]
21pub struct Scripting {
22    sources: Vec<Arc<dyn ScriptSource + Send + Sync + 'static>>,
23    merged_scripts: HashMap<String, HashSet<String>>,
24}
25
26impl Scripting {
27    pub fn try_new() -> Result<Self> {
28        Ok(Self {
29            sources: Default::default(),
30            merged_scripts: Default::default(),
31        })
32    }
33
34    ///
35    /// Registers a script from a rust_embed struct
36    ///
37    /// # Example
38    ///
39    /// ```
40    /// use quokka_templating::Scripting;
41    ///
42    /// #[derive(rust_embed::RustEmbed)]
43    /// #[folder = "../test_resources/scripts"]
44    /// #[include = "*.js"]
45    /// struct Scripts;
46    ///
47    /// let mut scripting = Scripting::try_new().unwrap();
48    ///
49    /// assert!(scripting.get_script("test.js").is_none());
50    ///
51    /// scripting.register_embedded_scripts::<Scripts>();
52    /// assert_eq!(scripting.get_script("test.js").unwrap(), "console.log(\"Hello World\");\n");
53    /// ```
54    ///
55    pub fn register_embedded_scripts<E: rust_embed::RustEmbed + Send + Sync + 'static>(&mut self) {
56        self.sources
57            .push(Arc::new(EmbedSource::<E>(Default::default())));
58    }
59
60    ///
61    /// Adds a script to the `default` group. Same as `Scripting.add_merged_script_group("default", path)`
62    ///
63    pub fn add_merged_script(&mut self, script: &str) {
64        self.add_merged_script_group(DEFAULT_SCRIPTS_GROUP, script);
65    }
66
67    ///
68    /// Adds a global script.
69    ///
70    /// The merged scripts are supposed to be loaded on all pages. Adding a big or a
71    /// lot of scripts will impact the sites performance and reduce the user experiance.
72    /// Use with caution.
73    ///
74    /// # Note
75    ///
76    /// The script name has to be a valid import part. Relative paths in the current
77    /// directory need to start with a `./`. An alphanumeric "word" will be seen as
78    /// an item of an import map [more details here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)
79    ///
80    /// # Example
81    ///
82    /// ```
83    /// use quokka_templating::Scripting;
84    ///
85    /// #[derive(rust_embed::RustEmbed)]
86    /// #[folder = "../test_resources/scripts"]
87    /// #[include = "*.js"]
88    /// struct Scripts;
89    ///
90    /// let mut scripting = Scripting::try_new().unwrap();
91    ///
92    /// assert!(scripting.get_merged_script().is_empty());
93    ///
94    /// scripting.add_merged_script_group("default", "./test.js");
95    /// assert_eq!(scripting.get_merged_script(), "import \"./test.js\";\n");
96    /// ```
97    ///
98    pub fn add_merged_script_group(&mut self, group: &str, script: &str) {
99        self.merged_scripts
100            .entry(group.to_string())
101            .or_default()
102            .insert(script.to_string());
103    }
104
105    ///
106    /// Gets a script by its path
107    ///
108    /// # Example
109    ///
110    /// ```
111    /// use quokka_templating::Scripting;
112    ///
113    /// #[derive(rust_embed::RustEmbed)]
114    /// #[folder = "../test_resources/scripts"]
115    /// #[include = "*.js"]
116    /// struct Scripts;
117    ///
118    /// let mut scripting = Scripting::try_new().unwrap();
119    ///
120    /// assert!(scripting.get_script("test.js").is_none());
121    ///
122    /// scripting.register_embedded_scripts::<Scripts>();
123    /// assert_eq!(scripting.get_script("test.js").unwrap(), "console.log(\"Hello World\");\n");
124    /// ```
125    ///
126    pub fn get_script(&self, path: &str) -> Option<String> {
127        for source in &self.sources {
128            if let Some(data) = source.read(path) {
129                return Some(data);
130            }
131        }
132
133        None
134    }
135
136    ///
137    /// Gets the `default` merged scripts. Same as `Scripting.get_merged_script_group("default")`
138    ///
139    pub fn get_merged_script(&self) -> String {
140        self.get_merged_script_group(DEFAULT_SCRIPTS_GROUP)
141    }
142
143    ///
144    /// Gets the merged script. Merged scripts are supposed to be used on all sides.
145    /// The merged script will rely on ECMAScript imports to get all the required scripts.
146    ///
147    /// # Example
148    ///
149    /// ```
150    /// use quokka_templating::Scripting;
151    ///
152    /// #[derive(rust_embed::RustEmbed)]
153    /// #[folder = "../test_resources/scripts"]
154    /// #[include = "*.js"]
155    /// struct Scripts;
156    ///
157    /// let mut scripting = Scripting::try_new().unwrap();
158    ///
159    /// assert!(scripting.get_merged_script().is_empty());
160    ///
161    /// scripting.add_merged_script("test.js");
162    /// assert_eq!(scripting.get_merged_script(), "import \"test.js\";\n");
163    /// ```
164    ///
165    pub fn get_merged_script_group(&self, group: &str) -> String {
166        use std::fmt::Write;
167        let mut out = String::new();
168
169        let Some(scripts) = self.merged_scripts.get(group) else {
170            return String::new();
171        };
172
173        for script in scripts {
174            writeln!(out, "import \"{script}\";").unwrap();
175        }
176
177        out
178    }
179}
180
181trait ScriptSource {
182    fn read(&self, path: &str) -> Option<String>;
183}
184
185struct EmbedSource<E>(PhantomData<E>);
186
187impl<E: rust_embed::RustEmbed> ScriptSource for EmbedSource<E> {
188    fn read(&self, path: &str) -> Option<String> {
189        let file = E::get(path)?;
190
191        #[cfg(feature = "script-utf16")]
192        return Some(String::from_utf16_lossy(&file.data).to_string());
193        #[cfg(not(feature = "script-utf16"))]
194        return Some(String::from_utf8_lossy(&file.data).to_string());
195    }
196}
197
198impl std::fmt::Debug for Scripting {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.write_str(
201            r#"Scripting {
202    sources: Vec<rust_embed::RustEmbed>,
203}"#,
204        )
205    }
206}
207
208impl TryFromConfig for Scripting {
209    type Error = crate::Error;
210
211    #[tracing::instrument]
212    async fn try_from_config(_: &Config) -> crate::Result<Self>
213    where
214        Self: Sized,
215    {
216        Self::try_new()
217    }
218}
219
220impl FromState<Scripting> for Scripting {
221    fn from_state(state: &Scripting) -> Self {
222        state.clone()
223    }
224}