fob_gen/
dev_ui.rs

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