Skip to main content

openapi_ui/
template.rs

1//! HTML template rendering for the documentation UI.
2
3use crate::openapi::OpenAPISpec;
4use crate::theme;
5
6/// The raw HTML template used for rendering.
7pub const TEMPLATE_HTML: &str = include_str!("index.html");
8
9const SAMPLE_DATA: &str = include_str!("sample_data.json");
10
11/// Renders the HTML template with the given spec, theme, and favicon.
12pub fn template(spec: &OpenAPISpec, theme_name: &str, favicon: &str) -> String {
13    let spec_json = serde_json::to_string(spec).unwrap_or_default();
14    let js_string = serde_json::to_string(&spec_json).unwrap_or_default();
15    let sample_data_js = serde_json::to_string(SAMPLE_DATA).unwrap_or_default();
16
17    let mode = theme_name.parse().unwrap_or(theme::ThemeMode::System);
18
19    TEMPLATE_HTML
20        .replace("{{light}}", &theme::ThemeMode::Light.get_css())
21        .replace("{{dark}}", &theme::ThemeMode::Dark.get_css())
22        .replace("{{theme}}", mode.as_str())
23        .replace("{{favicon}}", favicon)
24        .replace("/* SPEC_JSON_PLACEHOLDER */ null", &js_string)
25        .replace("/* SAMPLE_DATA_PLACEHOLDER */ null", &sample_data_js)
26}
27
28/// Renders the HTML template with optional custom CSS injected after the built-in themes.
29pub fn template_with_custom_theme(
30    spec: &OpenAPISpec,
31    theme_name: &str,
32    custom_css: Option<&str>,
33    favicon: &str,
34) -> String {
35    let spec_json = serde_json::to_string(spec).unwrap_or_default();
36    let js_string = serde_json::to_string(&spec_json).unwrap_or_default();
37    let sample_data_js = serde_json::to_string(SAMPLE_DATA).unwrap_or_default();
38
39    let mode = theme_name.parse().unwrap_or(theme::ThemeMode::System);
40    let inject_theme_script = mode == theme::ThemeMode::System;
41
42    // Inline script to set theme before page renders (prevents flash of wrong theme)
43    let theme_script = if inject_theme_script {
44        r#"<script>(function(){var t=localStorage.getItem("apidocs-theme");if(!t||t==="system"){t=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}document.documentElement.setAttribute("data-theme",t)})()</script>"#
45    } else {
46        ""
47    };
48
49    let light_content = theme::ThemeMode::Light.get_css();
50    let dark_content = theme::ThemeMode::Dark.get_css();
51
52    let mut html = TEMPLATE_HTML
53        .replace("{{light}}", &light_content)
54        .replace("{{dark}}", &dark_content)
55        .replace("{{theme}}", mode.as_str())
56        .replace("{{favicon}}", favicon)
57        .replace("/* SPEC_JSON_PLACEHOLDER */ null", &js_string)
58        .replace("/* SAMPLE_DATA_PLACEHOLDER */ null", &sample_data_js);
59
60    if inject_theme_script {
61        html = html.replace("<head>", &format!("<head>\n        {}", theme_script));
62    }
63
64    if let Some(css) = custom_css {
65        html = html.replace("</head>", &format!("<style>{}</style></head>", css));
66    }
67
68    html
69}
70
71/// Renders a demo template using the built-in Petstore sample data.
72pub fn base_template() -> String {
73    let sample_data_js = serde_json::to_string(SAMPLE_DATA).unwrap_or_default();
74
75    TEMPLATE_HTML
76        .replace("{{light}}", &theme::ThemeMode::Light.get_css())
77        .replace("{{dark}}", &theme::ThemeMode::Dark.get_css())
78        .replace("{{theme}}", "system")
79        .replace(
80            "{{favicon}}",
81            "https://www.openapis.org/wp-content/uploads/sites/31/2019/06/favicon-140x140.png",
82        )
83        .replace("/* SPEC_JSON_PLACEHOLDER */ null", "null")
84        .replace("/* SAMPLE_DATA_PLACEHOLDER */ null", &sample_data_js)
85}
86
87/// Renders the HTML template with embedded theme CSS.
88pub fn template_with_embedded_theme(spec: &OpenAPISpec, theme_name: &str, favicon: &str) -> String {
89    let spec_json = serde_json::to_string(spec).unwrap_or_default();
90    let js_string = serde_json::to_string(&spec_json).unwrap_or_default();
91    let sample_data_js = serde_json::to_string(SAMPLE_DATA).unwrap_or_default();
92
93    let mode = theme_name.parse().unwrap_or(theme::ThemeMode::System);
94
95    TEMPLATE_HTML
96        .replace("{{light}}", &theme::ThemeMode::Light.get_css())
97        .replace("{{dark}}", &theme::ThemeMode::Dark.get_css())
98        .replace("{{theme}}", mode.as_str())
99        .replace("{{favicon}}", favicon)
100        .replace("/* SPEC_JSON_PLACEHOLDER */ null", &js_string)
101        .replace("/* SAMPLE_DATA_PLACEHOLDER */ null", &sample_data_js)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::openapi::{Info, OpenAPISpec};
108    use std::collections::HashMap;
109
110    #[test]
111    fn test_template_generation() {
112        let spec = OpenAPISpec {
113            openapi: "3.0.0".to_string(),
114            info: Info {
115                title: "Test API".to_string(),
116                version: "1.0.0".to_string(),
117                description: Some("A test API".to_string()),
118                terms_of_service: None,
119                contact: None,
120                license: None,
121                x_logo: None,
122            },
123            servers: vec![],
124            paths: HashMap::new(),
125            components: None,
126            security: None,
127            tags: None,
128            external_docs: None,
129        };
130
131        let html = template(&spec, "dark", "favicon.ico");
132        assert!(html.contains("Test API"));
133        assert!(html.contains("3.0.0"));
134        assert!(html.contains("<!doctype html>"));
135    }
136
137    #[test]
138    fn test_template_with_custom_theme() {
139        let spec = OpenAPISpec {
140            openapi: "3.0.0".to_string(),
141            info: Info {
142                title: "Custom Theme API".to_string(),
143                version: "1.0.0".to_string(),
144                description: None,
145                terms_of_service: None,
146                contact: None,
147                license: None,
148                x_logo: None,
149            },
150            servers: vec![],
151            paths: HashMap::new(),
152            components: None,
153            security: None,
154            tags: None,
155            external_docs: None,
156        };
157
158        let custom_css = ":root { --accent: #ff0000; }";
159        let html = template_with_custom_theme(&spec, "light", Some(custom_css), "favicon.ico");
160        assert!(html.contains("Custom Theme API"));
161    }
162
163    #[test]
164    fn test_template_with_system_theme_injects_script() {
165        let spec = OpenAPISpec {
166            openapi: "3.0.0".to_string(),
167            info: Info {
168                title: "System Theme API".to_string(),
169                version: "1.0.0".to_string(),
170                description: None,
171                terms_of_service: None,
172                contact: None,
173                license: None,
174                x_logo: None,
175            },
176            servers: vec![],
177            paths: HashMap::new(),
178            components: None,
179            security: None,
180            tags: None,
181            external_docs: None,
182        };
183
184        let html = template_with_custom_theme(&spec, "system", None, "favicon.ico");
185        assert!(html.contains("apidocs-theme"));
186        assert!(html.contains("prefers-color-scheme"));
187        assert!(html.contains("data-theme=\"system\""));
188    }
189
190    #[test]
191    fn test_system_theme_script_content() {
192        let spec = OpenAPISpec {
193            openapi: "3.0.0".to_string(),
194            info: Info {
195                title: "Test".to_string(),
196                version: "1.0.0".to_string(),
197                description: None,
198                terms_of_service: None,
199                contact: None,
200                license: None,
201                x_logo: None,
202            },
203            servers: vec![],
204            paths: HashMap::new(),
205            components: None,
206            security: None,
207            tags: None,
208            external_docs: None,
209        };
210
211        let html = template_with_custom_theme(&spec, "system", None, "favicon.ico");
212
213        assert!(html.contains("localStorage.getItem(\"apidocs-theme\")"));
214        assert!(html.contains("matchMedia(\"(prefers-color-scheme: dark)\")"));
215
216        assert!(html.contains("if(!t||t===\"system\")"));
217
218        assert!(html.contains("setAttribute(\"data-theme\",t)"));
219    }
220
221    #[test]
222    fn test_base_template() {
223        let html = base_template();
224        assert!(html.contains("<!doctype html>"));
225        assert!(html.contains("<html"));
226        assert!(html.contains("INJECTED_SPEC"));
227    }
228
229    #[test]
230    fn test_template_with_embedded_theme() {
231        let spec = OpenAPISpec {
232            openapi: "3.0.0".to_string(),
233            info: Info {
234                title: "Embedded Theme API".to_string(),
235                version: "1.0.0".to_string(),
236                description: None,
237                terms_of_service: None,
238                contact: None,
239                license: None,
240                x_logo: None,
241            },
242            servers: vec![],
243            paths: HashMap::new(),
244            components: None,
245            security: None,
246            tags: None,
247            external_docs: None,
248        };
249
250        let html = template_with_embedded_theme(&spec, "dark", "favicon.ico");
251        assert!(html.contains("Embedded Theme API"));
252        assert!(html.contains("<!doctype html>"));
253    }
254}