fob_gen/
dev_ui.rs

1//! Development UI generators for dev server HTML/JS
2
3use crate::JsBuilder;
4use crate::error::Result;
5use oxc_allocator::Allocator;
6use oxc_ast::ast::Statement;
7
8/// HTML builder for generating dev server HTML
9pub struct HtmlBuilder<'a> {
10    js: JsBuilder<'a>,
11}
12
13impl<'a> HtmlBuilder<'a> {
14    /// Create a new HTML builder
15    pub fn new(allocator: &'a Allocator) -> Self {
16        Self {
17            js: JsBuilder::new(allocator),
18        }
19    }
20
21    /// Generate index.html for dev server
22    ///
23    /// Creates a minimal HTML shell that loads the JavaScript bundle
24    /// and includes hot reload script.
25    pub fn index_html(&self, entry_point: Option<&str>) -> Result<String> {
26        let script_src = entry_point.unwrap_or("/virtual_gumbo-client-entry.js");
27
28        // Generate HTML as a string (for now, since HTML isn't JS AST)
29        // TODO: Consider creating an HTML AST builder if needed
30        let html = format!(
31            r#"<!DOCTYPE html>
32<html lang="en">
33<head>
34    <meta charset="UTF-8">
35    <meta name="viewport" content="width=device-width, initial-scale=1.0">
36    <meta name="description" content="Fob application">
37    <!-- React 19 will inject title and additional meta tags here -->
38    <title>Fob Dev Server</title>
39</head>
40<body>
41    <!-- React root mount point -->
42    <div id="root"></div>
43
44    <!-- Application bundle -->
45    <script type="module" src="{}"></script>
46
47    <!-- Hot reload for development -->
48    <script src="/__fob_reload__.js"></script>
49</body>
50</html>"#,
51            script_src
52        );
53
54        Ok(html)
55    }
56
57    /// Generate error overlay HTML
58    ///
59    /// Creates an HTML error page displayed in the browser when builds fail.
60    /// Auto-dismisses and reloads when the next build succeeds.
61    pub fn error_overlay(&self, error: &str) -> Result<String> {
62        let escaped_error = html_escape(error);
63
64        let html = format!(
65            r#"<!DOCTYPE html>
66<html lang="en">
67<head>
68    <meta charset="UTF-8">
69    <meta name="viewport" content="width=device-width, initial-scale=1.0">
70    <title>Build Error - Fob Dev Server</title>
71    <style>
72        * {{
73            margin: 0;
74            padding: 0;
75            box-sizing: border-box;
76        }}
77
78        body {{
79            font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
80            background: #1a1a1a;
81            color: #e8e8e8;
82            padding: 20px;
83            line-height: 1.6;
84        }}
85
86        .container {{
87            max-width: 1200px;
88            margin: 0 auto;
89        }}
90
91        .header {{
92            background: #ff4444;
93            color: white;
94            padding: 20px 30px;
95            border-radius: 8px 8px 0 0;
96            font-size: 18px;
97            font-weight: bold;
98            display: flex;
99            align-items: center;
100            gap: 10px;
101        }}
102
103        .icon {{
104            font-size: 24px;
105        }}
106
107        .error-content {{
108            background: #2a2a2a;
109            padding: 30px;
110            border-radius: 0 0 8px 8px;
111            border: 2px solid #ff4444;
112            border-top: none;
113        }}
114
115        pre {{
116            background: #1a1a1a;
117            padding: 20px;
118            border-radius: 4px;
119            overflow-x: auto;
120            white-space: pre-wrap;
121            word-wrap: break-word;
122            color: #ff6b6b;
123            border-left: 4px solid #ff4444;
124        }}
125
126        .actions {{
127            margin-top: 20px;
128            display: flex;
129            gap: 10px;
130        }}
131
132        button {{
133            background: #4a9eff;
134            color: white;
135            border: none;
136            padding: 12px 24px;
137            border-radius: 6px;
138            cursor: pointer;
139            font-size: 14px;
140            font-weight: 500;
141            transition: background 0.2s;
142        }}
143
144        button:hover {{
145            background: #3a8eef;
146        }}
147
148        button:active {{
149            background: #2a7edf;
150        }}
151
152        .info {{
153            margin-top: 20px;
154            padding: 15px;
155            background: #2a3a4a;
156            border-radius: 4px;
157            border-left: 4px solid #4a9eff;
158            color: #a8c8e8;
159        }}
160
161        .footer {{
162            margin-top: 30px;
163            text-align: center;
164            color: #888;
165            font-size: 12px;
166        }}
167    </style>
168</head>
169<body>
170    <div class="container">
171        <div class="header">
172            <span class="icon">⚠️</span>
173            <span>Build Error</span>
174        </div>
175        <div class="error-content">
176            <pre>{}</pre>
177            <div class="actions">
178                <button onclick="location.reload()">Retry Build</button>
179            </div>
180            <div class="info">
181                This error will automatically disappear once the build succeeds.
182                The page will reload automatically.
183            </div>
184        </div>
185        <div class="footer">
186            Fob Dev Server
187        </div>
188    </div>
189
190    <script>
191        // Connect to SSE for auto-reload on success
192        const eventSource = new EventSource('/__fob_sse__');
193
194        eventSource.addEventListener('message', (event) => {{
195            try {{
196                const data = JSON.parse(event.data);
197                if (data.type === 'BuildCompleted') {{
198                    // Build succeeded, reload the page
199                    location.reload();
200                }}
201            }} catch (e) {{
202                console.error('Failed to parse SSE event:', e);
203            }}
204        }});
205
206        eventSource.addEventListener('error', () => {{
207            // Reconnect on error (handled by EventSource automatically)
208            console.log('SSE connection lost, will reconnect...');
209        }});
210    </script>
211</body>
212</html>"#,
213            escaped_error
214        );
215
216        Ok(html)
217    }
218
219    /// Inject an import map script tag into HTML
220    ///
221    /// Adds a `<script type="importmap">` tag with the provided JSON content
222    /// before the closing `</head>` tag, or at the beginning if no `</head>` is found.
223    ///
224    /// # Arguments
225    ///
226    /// * `html` - Existing HTML content
227    /// * `import_map_json` - JSON string for the import map
228    ///
229    /// # Returns
230    ///
231    /// HTML string with import map injected
232    pub fn inject_import_map(&self, html: &str, import_map_json: &str) -> String {
233        let snippet = format!(r#"<script type="importmap">{}</script>"#, import_map_json);
234
235        if let Some(idx) = html.find("</head>") {
236            let (head, tail) = html.split_at(idx);
237            format!("{}{}{}", head, snippet, tail)
238        } else {
239            format!("{}{}", snippet, html)
240        }
241    }
242
243    /// Generate route manifest JavaScript
244    ///
245    /// Creates a JavaScript module exporting route configuration
246    /// with lazy-loaded components.
247    pub fn route_manifest(&self, routes: &[RouteSpec]) -> Result<String> {
248        let route_objects: Vec<_> = routes
249            .iter()
250            .map(|route| {
251                self.js.object(vec![
252                    self.js.prop("path", self.js.string(route.path.as_str())),
253                    self.js.prop("id", self.js.string(route.id.as_str())),
254                    self.js.prop(
255                        "component",
256                        self.js.call(
257                            self.js.ident("lazy"),
258                            vec![self.js.arg(self.js.arrow_fn(
259                                vec![],
260                                self.js.call(
261                                    self.js.ident("import"),
262                                    vec![self.js.arg(self.js.string(route.file.as_str()))],
263                                ),
264                            ))],
265                        ),
266                    ),
267                ])
268            })
269            .collect();
270
271        let routes_array = self.js.array(route_objects);
272        let routes_decl = self.js.const_decl("routes", routes_array);
273        let export_default = self.js.export_default(self.js.ident("routes"));
274
275        self.js
276            .program(vec![routes_decl, Statement::from(export_default)])
277    }
278}
279
280/// Route specification for manifest generation
281#[derive(Debug, Clone)]
282pub struct RouteSpec {
283    /// Route path (e.g., "/", "/about", "/blog/:slug")
284    pub path: String,
285    /// Route ID (e.g., "index", "about", "blog_post")
286    pub id: String,
287    /// Component file path (e.g., "./routes/index.tsx")
288    pub file: String,
289}
290
291/// HTML-escape a string to prevent XSS attacks
292fn html_escape(s: &str) -> String {
293    s.chars()
294        .map(|c| match c {
295            '&' => "&amp;".to_string(),
296            '<' => "&lt;".to_string(),
297            '>' => "&gt;".to_string(),
298            '"' => "&quot;".to_string(),
299            '\'' => "&#x27;".to_string(),
300            _ => c.to_string(),
301        })
302        .collect()
303}