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}