tera_shortcodes/lib.rs
1//
2// shortcode module
3//
4
5use tera::{Result, Function};
6use std::collections::HashMap;
7use once_cell::sync::Lazy;
8
9static CLIENT: Lazy<reqwest::Client> = Lazy::new(|| reqwest::Client::new());
10
11// const ROBOTS_TXT: &'static str = "Link for Robots (No JavaScript)";
12
13/// A struct that manages shortcode functions for use in Tera templates.
14///
15/// # Fields
16///
17/// - `functions`: A `HashMap` where the key is the shortcode display name (a `String`), and the value is a function pointer that takes a reference to a `HashMap` of arguments and returns a `String` representing the generated content.
18pub struct Shortcodes {
19 pub functions: HashMap<String, fn(&HashMap<String, tera::Value>) -> String>,
20}
21
22impl Shortcodes {
23
24 /// Creates a new `Shortcodes` instance with an empty set of registered functions.
25 ///
26 /// # Returns
27 ///
28 /// A `Shortcodes` struct with an empty `functions` map.
29 pub fn new() -> Self {
30 Shortcodes {
31 functions: HashMap::new(),
32 }
33 }
34
35 /// Registers a new shortcode function in the `Shortcodes` struct.
36 ///
37 /// # Parameters
38 ///
39 /// - `display`: The shortcode display name as a `&str`, which will be used as the key in the `functions` map.
40 /// - `shortcode_fn`: A function pointer that takes a `HashMap` of arguments and returns a `String`.
41 ///
42 /// # Returns
43 ///
44 /// An updated instance of `Shortcodes` with the newly registered shortcode function.
45 ///
46 /// # Example
47 ///
48 /// ```rust
49 /// use tera_shortcodes::Shortcodes;
50 ///
51 /// let shortcodes = Shortcodes::new().register("example", |args| {
52 /// "Shortcode output".to_string()
53 /// });
54 /// ```
55 pub fn register(mut self,
56 display: &str,
57 shortcode_fn: fn(&HashMap<String, tera::Value>) -> String,
58 ) -> Self {
59 self.functions.insert(display.to_owned(), shortcode_fn);
60 self
61 }
62
63}
64
65impl Function for Shortcodes {
66
67 /// Invokes a registered shortcode function by its display name.
68 ///
69 /// # Parameters
70 ///
71 /// - `args`: A reference to a `HashMap` containing the arguments passed to the shortcode function.
72 ///
73 /// # Returns
74 ///
75 /// A `Result<tera::Value>` that contains the generated content as a `String` or an error message if the display name is missing or unknown.
76 ///
77 /// # Error Handling
78 ///
79 /// - If the `display` attribute is missing, it returns an error message `"Missing display attribute"`.
80 /// - If no function is registered for the given display name, it returns an error message `"Unknown shortcode display name: <display>"`.
81 fn call(&self,
82 args: &HashMap<String, tera::Value>,
83 ) -> Result<tera::Value> {
84
85 let display = match args.get("display") {
86 Some(value) => value.as_str()
87 .unwrap()
88 .trim_matches(|c| c == '"' || c == '\''),
89 None => return Ok(tera::Value::String("Missing display attribute".to_owned())),
90 };
91
92 let fragment = match self.functions.get(display) {
93 Some(shortcode_fn) => shortcode_fn(args),
94 None => {
95 return Ok(tera::Value::String(format!("Unknown shortcode display name: {}", display)))
96 },
97 };
98
99 Ok(tera::Value::String(fragment))
100
101 }
102}
103
104/// Generates a JavaScript snippet that asynchronously fetches data from a URL using either the GET
105/// or POST HTTP method and injects the response into the DOM. The function also provides fallback
106/// content for crawlers/robots that do not support JavaScript. If the response has JavaScript code
107/// like <script>console.log('test');</script>, it will be executable.
108///
109/// # Parameters
110///
111/// - `url`: A string slice containing the URL to which the HTTP request will be made.
112/// - `method`: An optional HTTP method, either `GET` or `POST`. Defaults to `GET` if `None` is provided.
113/// - `json_body`: An optional JSON string for the request body when using the `POST` method. Defaults to
114/// an empty JSON object (`{}`) if `None` is provided. Ignored if the method is `GET`.
115/// - `alt`: An optional alternative content to display in a `<noscript>` block for crawlers/robots without JavaScript.
116/// This is only used if the method is `GET`. Defaults to `None`.
117///
118/// # Returns
119///
120/// A `String` containing the generated JavaScript code that can be inserted into an HTML page. The script:
121/// - Sends an asynchronous `fetch` request to the specified URL.
122/// - If the response is successful, it injects the response content into the DOM.
123/// - If the request fails, it logs an error message to the browser's console.
124/// - If an invalid HTTP method is passed (anything other than `GET` or `POST`), an HTML `<output>` element
125/// with an error message is returned instead of the JavaScript code.
126///
127/// If the `GET` method is used and `alt` is provided, the function also includes a `<noscript>` fallback
128/// to display a link in case JavaScript is disabled or not supported.
129///
130/// # Example
131///
132/// ```rust
133/// use tera_shortcodes::fetch_shortcode_js;
134///
135/// let js_code = fetch_shortcode_js(
136/// "https://example.com/data",
137/// Some("POST"),
138/// Some("{\"key\": \"value\"}"),
139/// Some("No JavaScript fallback")
140/// );
141///
142/// println!("{}", js_code);
143/// ```
144///
145/// This will generate JavaScript code to make a `POST` request to `https://example.com/data` with the
146/// provided JSON body, and include a fallback for users without JavaScript.
147///
148/// # Error Handling
149///
150/// - If an unsupported HTTP method is provided (anything other than `GET` or `POST`), the function will
151/// return an HTML `<output>` element with an error message specifying the invalid method.
152pub fn fetch_shortcode_js(
153 url: &str,
154 method: Option<&str>,
155 json_body: Option<&str>,
156 alt: Option<&str>,
157) -> String {
158
159 let method = method.unwrap_or("GET");
160 let json_body = json_body.unwrap_or("{}");
161
162 let fetch_js = match method.to_lowercase().as_str() {
163 "get" => format!(r#"const r=await fetch("{}");"#, url),
164 "post" => format!(r#"const q=new Request('{}',{{headers:(()=>{{const h=new Headers();h.append('Content-Type','application/json');return h;}})(),method:'POST',body:JSON.stringify({})}});const r=await fetch(q);"#,
165 url, json_body),
166 _ => return format!(r#"<output style="background-color:#f44336;color:#fff;padding:6px;">
167Invalid method {} for url {} (only GET and POST methods available)
168</output>"#, method, url),
169 };
170
171 // The non-minified full code of this JavaScript script can be found in the js directory located in the root directory.
172 // reScript function is a trick to make the Javascript code work when inserted.
173 // Replace it with another clone element script.
174 let js_code = format!(r#"<script>(function(){{async function f(){{try{{{}if(!r.ok){{throw new Error(`HTTP error! Status: ${{r.status}}`);}}return await r.text();}}catch(error){{console.error('Fetch failed:',error);return '';}}}}function s(h){{for(const n of h.childNodes){{if(n.hasChildNodes()){{s(n);}}if(n.nodeName==='SCRIPT'){{const e=document.createElement('script');e.type='text/javascript';e.textContent=n.textContent;n.replaceWith(e);}}}}}}(async ()=>{{const e=document.currentScript;const c=await f();const h=document.createElement('div');h.id='helper';h.innerHTML=c;s(h);e.after(...h.childNodes);e.remove();}})();}})();</script>"#,
175 fetch_js);
176
177 if method.to_lowercase().as_str() == "get" && alt.is_some() {
178 let alt = alt.unwrap();
179 js_code.to_string() + &format!(r#"<noscript><a href="{}">{}</a></noscript>"#, url, alt)
180 } else {
181 js_code
182 }
183}
184
185/// Sends an HTTP request to the provided URL using either the `GET` or `POST` method and returns the response as a String.
186/// This function handles asynchronous requests but executes them in a synchronous context using Tokio function `block_in_place`.
187/// Note: This function is slow. For better performance, consider using the fetch_shortcode_js function instead.
188///
189/// # Parameters
190///
191/// - `url`: A string slice that holds the URL to which the HTTP request will be sent.
192/// - `method`: An optional HTTP method, either `GET` or `POST`. Defaults to `GET` if `None` is provided.
193/// - `json_body`: An optional JSON string to be used as the request body for `POST` requests.
194/// Defaults to an empty JSON object (`{}`) if `None` is provided. This parameter is ignored for `GET` requests.
195///
196/// # Returns
197///
198/// A `String` containing either the response body from the server or an error message in case
199/// of failure.
200/// - If the HTTP request succeeds and returns a valid response, the body of the response is returned as a `String`.
201/// - If the HTTP request fails (due to network errors, invalid URLs, or server errors), a descriptive error message is returned.
202///
203/// # Error Handling
204///
205/// - If an invalid HTTP method is provided, the function returns `"Invalid method: <method>"`.
206/// - If the request fails, either due to network issues or an unsuccessful HTTP status, the function returns
207/// an error message like `"Request failed with status: <status>"` or `"Request error: <error>"`.
208///
209/// # Blocking and Asynchronous Execution
210///
211/// This function uses `tokio::task::block_in_place` to run the asynchronous request synchronously.
212/// This allows the function to be used in synchronous contexts while still performing asynchronous
213/// operations under the hood.
214///
215/// # Example
216///
217/// ```rust
218/// use tera_shortcodes::fetch_shortcode;
219///
220/// #[tokio::main]
221/// async fn main() {
222/// let response = fetch_shortcode(
223/// "https://example.com/api",
224/// Some("POST"),
225/// Some(r#"{"key": "value"}"#)
226/// );
227///
228/// println!("Response: {}", response);
229/// }
230/// ```
231///
232/// This will perform a `POST` request to `https://example.com/api` with the given JSON body and print the response.
233pub fn fetch_shortcode(
234 url: &str,
235 method: Option<&str>,
236 json_body: Option<&str>,
237) -> String {
238
239 let method = method.unwrap_or("GET");
240 let json_body = json_body.unwrap_or("{}");
241
242 let data_to_route = async {
243 let response = match method.to_lowercase().as_str() {
244 "get" => CLIENT.get(url)
245 .send()
246 .await,
247 "post" => CLIENT.post(url)
248 .header("Content-Type", "application/json")
249 .body(json_body.to_owned())
250 .send()
251 .await,
252 _ => return format!("Invalid method: {}", method),
253 };
254
255 match response {
256 Ok(res) => {
257 if res.status().is_success() {
258 res.text().await.unwrap_or_else(|_| "Failed to read response body".into())
259 } else {
260 format!("Request failed with status: {}", res.status())
261 }
262 }
263 Err(e) => format!("Request error: {}", e),
264 }
265 };
266
267 // Use `block_in_place` to run the async function
268 // within the blocking context
269 tokio::task::block_in_place(||
270 // We need to access the current runtime to
271 // run the async function
272 tokio::runtime::Handle::current()
273 .block_on(data_to_route)
274 )
275}