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}