rumtk_web/utils/render.rs
1/*
2 * rumtk attempts to implement HL7 and medical protocols for interoperability in medicine.
3 * This toolkit aims to be reliable, simple, performant, and standards compliant.
4 * Copyright (C) 2025 Luis M. Santos, M.D. <lsantos@medicalmasses.com>
5 * Copyright (C) 2025 Ethan Dixon
6 * Copyright (C) 2025 MedicalMasses L.L.C. <contact@medicalmasses.com>
7 *
8 * This program is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License
19 * along with this program. If not, see <https://www.gnu.org/licenses/>.
20 */
21use crate::types::HTMLResult;
22use crate::{RUMWebRedirect, RUMWebTemplate};
23use pulldown_cmark::Options;
24use rumtk_core::base::RUMResult;
25use rumtk_core::search::rumtk_search::string_replace_all_matches;
26use rumtk_core::strings::{
27 rumtk_format, AsStr, GraphemePatternPair, RUMString,
28};
29use std::sync::LazyLock;
30
31pub static MARKDOWN_OPTIONS: LazyLock<Options> = LazyLock::new(|| -> Options {
32 let mut options = Options::empty();
33
34 options.insert(Options::ENABLE_STRIKETHROUGH);
35 options.insert(Options::ENABLE_TASKLISTS);
36 options.insert(Options::ENABLE_MATH);
37 options.insert(Options::ENABLE_TABLES);
38 options.insert(Options::ENABLE_WIKILINKS);
39
40 options
41 }
42);
43
44const TEMPLATE_NEWLINE_COMPONENT_PATTERN: GraphemePatternPair<'static> = (&["<"], &[">"]);
45const TEMPLATE_NEWLINE_COMPONENT_INNER_PATTERN: GraphemePatternPair<'static> =
46 (&[">", "\n"], &["<"]);
47const TEMPLATE_MIDDLE_REGEX: &str = ">\\s+<";
48const TEMPLATE_MIDDLE_REPLACEMENT: &str = "><";
49
50#[derive(RUMWebTemplate)]
51#[template(
52 source = "
53 {% for element in elements %}
54 {{ element|safe }}
55 {% endfor %}
56 ",
57 ext = "html"
58)]
59struct ContentBlock<'a> {
60 elements: &'a [RUMString],
61}
62
63///
64/// This function trims excess newlines and whitespacing outside tag block (e.g. `<div></div>`). The
65/// idea is to cleanup the rendered template which picks up extra characters due to the way string
66/// literals work in proc macros.
67///
68/// This is not meant to be used as a sanitization function!
69///
70/// This function consumes the input string!!!!!
71///
72/// ## Example
73/// ```
74/// use rumtk_web::rumtk_web_trim_rendered_html;
75/// use rumtk_web::testdata::data::{TRIMMED_HTML_RENDER, UNTRIMMED_HTML_RENDER};
76///
77/// let expected = String::from(TRIMMED_HTML_RENDER);
78/// let input = String::from(UNTRIMMED_HTML_RENDER);
79/// let filtered = rumtk_web_trim_rendered_html(input).unwrap();
80///
81/// assert_eq!(filtered, expected, "Template render trim failed!");
82/// ```
83///
84#[inline]
85pub fn rumtk_web_trim_rendered_html(html: String) -> RUMResult<String> {
86 let filtered = html.as_grapheme_str()
87 .trim(&TEMPLATE_NEWLINE_COMPONENT_PATTERN)
88 .trim(&TEMPLATE_NEWLINE_COMPONENT_PATTERN)
89 .to_string();
90 string_replace_all_matches(filtered.as_str(), TEMPLATE_MIDDLE_REGEX, TEMPLATE_MIDDLE_REPLACEMENT)
91}
92
93#[inline]
94pub fn rumtk_web_post_process(html: String, url: RUMWebRedirect) -> HTMLResult {
95 let filtered = rumtk_web_trim_rendered_html(html)?;
96 Ok(url.into_web_response(Some(filtered)))
97}
98
99///
100/// Render the given component template into an `HTML Body response` or a `URL Redirect response`.
101/// If you provide the [RUMWebRedirect] in the `url` parameter configured for redirection, then we
102/// return the redirection as the response. Otherwise, we render the HTML and save it in the response.
103///
104/// ## Example
105/// ```
106/// use rumtk_web::{HTMLBody, RUMString, RUMWebRedirect, RUMWebResponse};
107/// use rumtk_web::RUMWebTemplate;
108/// use rumtk_web::rumtk_web_render;
109///
110/// #[derive(RUMWebTemplate)]
111/// #[template(
112/// source = "<div></div>",
113/// ext = "html"
114/// )]
115/// struct Div { }
116///
117/// let result = rumtk_web_render(Div{}, RUMWebRedirect::None).unwrap();
118/// let expected = RUMWebResponse::into_get_response("<div></div>");
119///
120/// assert_eq!(result, expected, "Test Div template rendered improperly!");
121/// ```
122///
123#[inline]
124pub fn rumtk_web_render<T: RUMWebTemplate>(template: T, url: RUMWebRedirect) -> HTMLResult {
125 let result = template.render();
126 match result {
127 Ok(html) => {
128 rumtk_web_post_process(html, url)
129 }
130 Err(e) => {
131 let tn = std::any::type_name::<T>();
132 Err(rumtk_format!("Template {tn} render failed: {e:?}"))
133 }
134 }
135}
136
137#[inline]
138pub fn rumtk_web_render_contents(elements: &[RUMString]) -> HTMLResult {
139 rumtk_web_render(ContentBlock { elements }, RUMWebRedirect::None)
140}
141
142#[inline]
143pub fn rumtk_web_redirect(url: RUMWebRedirect) -> HTMLResult {
144 Ok(url.into_web_response(Some(String::default())))
145}
146
147///
148/// Render component into an HTML Response Body of type [HTMLResult]. This macro is a bit more complex.
149/// Depending on the arguments passed to it, it can
150///
151/// 1. Call a component function that receives exactly 0 parameters.
152/// 2. Call a component function that only receives the [SharedAppState](crate::utils::SharedAppState) handle as its only parameter.
153/// 3. Call a component function that can accept the standard set of parameters (`path`, `params`, and `app_state`). However, the Path is set to empty.
154/// 4. Call a component function that can accept the standard set of parameters (`path`, `params`, and `app_state`). All of these parameters are passed through to the function.
155///
156/// The reason for this set of behaviors is that we have standard component functions which are found in [components](crate::components) modules.
157/// These functions are of type [ComponentFunction](crate::utils::ComponentFunction) and the expected parameters are as follows:
158///
159/// 1. `path` => [URLPath](crate::utils::URLPath)
160/// 2. `params` => [URLParams](crate::utils::URLParams)
161/// 3. `app_state` => [SharedAppState](crate::utils::SharedAppState)
162///
163/// The component functions are the bread and butter of the framework and are what are expected from consumers of
164/// this library. They get registered to an internal `Map` that we use as a sort of `vTable` to dispatch the correct user function.
165/// **In this case, the component function parameter for this macro is a stringview type since we perform the lookup automatically!**
166///
167/// The reason for the other usages is that we also have static components whose only purpose are to define
168/// pre-selected items to help make web apps come together in an easy to use package. These include the
169/// `htmx` and `fontawesome` imports. Perhaps, we will open up this facility to the user in later iterations of the framework
170/// to make it easy to override and include other static assets and maybe for prefetch and optimization purposes.
171///
172/// ## Examples
173///
174/// ### Simple Component Render
175/// ```
176/// use rumtk_web::components::app::css::css;
177/// use rumtk_web::utils::testdata::data::TRIMMED_HTML_RENDER_CSS;
178/// use rumtk_web::rumtk_web_render_component;
179///
180/// let rendered = rumtk_web_render_component!(css);
181/// let expected = TRIMMED_HTML_RENDER_CSS;
182///
183/// assert_eq!(rendered, expected, "Commponent rendered improperly!");
184/// ```
185///
186/// ### Component Render with Shared State
187/// ```
188/// use rumtk_web::SharedAppState;
189/// use rumtk_web::components::app::meta::meta;
190/// use rumtk_web::utils::testdata::data::TRIMMED_HTML_RENDER_META;
191/// use rumtk_web::rumtk_web_render_component;
192///
193/// let state = SharedAppState::default();
194/// let rendered = rumtk_web_render_component!(meta, state);
195///
196/// assert_eq!(rendered, TRIMMED_HTML_RENDER_META, "Commponent rendered improperly!");
197/// ```
198///
199/// ### Component Render with Standard Parameters
200/// ```
201/// use rumtk_web::{rumtk_web_collect_page, rumtk_web_params_map, rumtk_web_trim_rendered_html, SharedAppState};
202/// use rumtk_web::defaults::PARAMS_TITLE;
203/// use rumtk_web::utils::testdata::data::TRIMMED_HTML_TITLE_RENDER;
204/// use rumtk_web::components::title::title;
205///
206/// let state = SharedAppState::default();
207/// let params = rumtk_web_params_map!([(PARAMS_TITLE, "Hello World!")]);
208/// let rendered = title(&[], params.get_inner(), state).unwrap().to_string();
209/// let trimmed = rumtk_web_trim_rendered_html(rendered).unwrap();
210///
211/// assert_eq!(&trimmed, TRIMMED_HTML_TITLE_RENDER, "Commponent rendered improperly!");
212/// ```
213///
214#[macro_export]
215macro_rules! rumtk_web_render_component {
216 ( $component_fxn:expr ) => {{
217 use $crate::render::rumtk_web_trim_rendered_html;
218 use rumtk_core::strings::{RUMString, RUMStringConversions};
219 match $component_fxn() {
220 Ok(x) => match rumtk_web_trim_rendered_html(x.to_string()) {
221 Ok(r) => r,
222 _ => RUMString::default()
223 },
224 _ => RUMString::default(),
225 }
226 }};
227 ( $component_fxn:expr, $app_state:expr ) => {{
228 use $crate::render::rumtk_web_trim_rendered_html;
229 use rumtk_core::strings::{RUMString, RUMStringConversions};
230 match $component_fxn($app_state.clone()) {
231 Ok(x) => match rumtk_web_trim_rendered_html(x.to_string()) {
232 Ok(r) => r,
233 _ => RUMString::default()
234 },
235 _ => RUMString::default(),
236 }
237 }};
238 ( $component:expr, $params:expr, $app_state:expr ) => {{
239 rumtk_web_render_component!($component, &[""], $params, $app_state)
240 }};
241 ( $component:expr, $path:expr, $params:expr, $app_state:expr ) => {{
242 use $crate::components::html::div;
243 use $crate::render::rumtk_web_trim_rendered_html;
244 use $crate::{rumtk_web_get_component, rumtk_web_params_map};
245
246 let params = rumtk_web_params_map!($params);
247
248 match rumtk_web_get_component!($component) {
249 Some(component) => match component($path, params.get_inner(), $app_state.clone()){
250 Ok(x) => match rumtk_web_trim_rendered_html(x.to_string()) {
251 Ok(r) => r,
252 _ => RUMString::default()
253 },
254 _ => RUMString::default(),
255 },
256 // This is tricky, but I could not decide if the correct option here was to pass an
257 // message or default to a blank div. I chose the div, but if something changes, feel
258 // free to reconsider.
259 None => div($path, params.get_inner(), $app_state.clone()) {
260 Ok(x) => match rumtk_web_trim_rendered_html(x.to_string()) {
261 Ok(r) => r,
262 _ => RUMString::default()
263 },
264 _ => RUMString::default(),
265 }
266 }
267 }};
268}
269
270#[macro_export]
271macro_rules! rumtk_web_render_template {
272 ( $page:expr ) => {{
273 use $crate::utils::{rumtk_web_render, RUMWebRedirect};
274
275 rumtk_web_render($page, RUMWebRedirect::None)
276 }};
277 ( $page:expr, $redirect_url:expr ) => {{
278 use $crate::utils::rumtk_web_render;
279
280 rumtk_web_render($page, $redirect_url)
281 }};
282}
283
284#[macro_export]
285macro_rules! rumtk_web_post_process_html {
286 ( $html:expr ) => {{
287 use rumtk_core::strings::{RUMStringConversions};
288 use $crate::utils::{rumtk_web_post_process, RUMWebRedirect};
289
290 rumtk_web_post_process($html.to_string(), RUMWebRedirect::None)
291 }};
292 ( $html:expr, $redirect_url:expr ) => {{
293 use rumtk_core::strings::{RUMStringConversions};
294 use $crate::utils::rumtk_web_post_process;
295
296 rumtk_web_post_process($html.to_string(), $redirect_url)
297 }};
298}
299
300///
301/// Generates the HTML page as prescribed by the input `page` function of type [HTMLResult].
302///
303/// ## Example
304/// ```
305/// use rumtk_core::strings::RUMString;
306/// use rumtk_web::defaults::{PARAMS_TYPE};
307/// use rumtk_web::pages::index::index;
308/// use rumtk_web::components::html::div;
309/// use rumtk_web::{rumtk_web_params_map, rumtk_web_render_component, rumtk_web_render_page_contents, SharedAppState};
310///
311/// let app_state = SharedAppState::default();
312/// let params = rumtk_web_params_map!([("", "")]);
313/// let mydiv = div("story", params.get_inner(), app_state).unwrap().to_string();
314///
315/// let expected_page = RUMString::from("<div class='div-default'>story</div>");
316/// let page_response = rumtk_web_render_page_contents!(
317/// &vec![
318/// mydiv
319/// ]
320/// ).expect("Page rendered!");
321/// let rendered_page = page_response.to_string();
322///
323/// assert_eq!(rendered_page, expected_page, "Page was not rendered properly!")
324/// ```
325///
326#[macro_export]
327macro_rules! rumtk_web_render_page_contents {
328 ( $page_elements:expr ) => {{
329 use $crate::utils::rumtk_web_render_contents;
330
331 rumtk_web_render_contents($page_elements)
332 }};
333}
334
335///
336/// Generate redirect response automatically instead of actually rendering an HTML page.
337///
338/// ## Examples
339///
340/// ### Temporary Redirect
341/// ```
342/// use rumtk_web::RUMStringConversions;
343/// use rumtk_web::utils::response::RUMWebRedirect;
344/// use rumtk_web::rumtk_web_render_redirect;
345///
346/// let url = "http://localhost/redirected";
347/// let redirect = rumtk_web_render_redirect!(RUMWebRedirect::RedirectTemporary(url.to_string()));
348///
349/// let result = redirect.expect("Failed to create the redirect response!").get_url();
350///
351/// assert_eq!(result, url, "Url in Response object does not match the expected!");
352///
353/// ```
354///
355#[macro_export]
356macro_rules! rumtk_web_render_redirect {
357 ( $url:expr ) => {{
358 use $crate::utils::rumtk_web_redirect;
359
360 rumtk_web_redirect($url)
361 }};
362}
363
364///
365///
366/// If using raw strings, do not leave an extra line. The first input must have characters, or you
367/// will get <pre><code> blocks regardless of what you do.
368///
369/// ## Example
370/// ```
371/// use rumtk_web::rumtk_web_render_markdown;
372///
373/// let md = r###"
374///**Hello World**
375/// "###;
376/// let expected_html = "<p><strong>Hello World</strong></p>\n";
377///
378/// let result = rumtk_web_render_markdown!(md);
379///
380/// assert_eq!(result, expected_html, "The rendered markdown does not match the expected HTML!");
381/// ```
382///
383#[macro_export]
384macro_rules! rumtk_web_render_markdown {
385 ( $md:expr ) => {{
386 use pulldown_cmark::{Options, Parser};
387 use rumtk_core::strings::RUMStringConversions;
388 use $crate::utils::render::{MARKDOWN_OPTIONS};
389
390 let input = String::from($md);
391 let parser = Parser::new_ext(&input, unsafe {(*MARKDOWN_OPTIONS).clone()});
392 let mut html_output = String::new();
393 pulldown_cmark::html::push_html(&mut html_output, parser);
394
395 html_output.to_string()
396 }};
397}