1use crate::JsBuilder;
4use crate::error::Result;
5use oxc_allocator::Allocator;
6use oxc_ast::ast::Statement;
7
8pub struct HtmlBuilder<'a> {
10 js: JsBuilder<'a>,
11}
12
13impl<'a> HtmlBuilder<'a> {
14 pub fn new(allocator: &'a Allocator) -> Self {
16 Self {
17 js: JsBuilder::new(allocator),
18 }
19 }
20
21 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 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 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 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 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#[derive(Debug, Clone)]
282pub struct RouteSpec {
283 pub path: String,
285 pub id: String,
287 pub file: String,
289}
290
291fn html_escape(s: &str) -> String {
293 s.chars()
294 .map(|c| match c {
295 '&' => "&".to_string(),
296 '<' => "<".to_string(),
297 '>' => ">".to_string(),
298 '"' => """.to_string(),
299 '\'' => "'".to_string(),
300 _ => c.to_string(),
301 })
302 .collect()
303}