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