Setting the file. One moment.
Extract Inline HTML · Stitch::extract Static HTML · google-labs-code/stitch-skills · Skills Docs
ContentsBack to the top of the page function extractCssUrls
— line 408
This file
Number 8.1
Position 1 of 3
Type TypeScript
Size 34 KB
Lines 1,025 scripts/ extract_inline_html.ts
TypeScript · 1,025 lines · 34 KB
16
* --page Page spec as src_file:dst_filename:title (repeatable)
17 * --tailwind-config Path to tailwind.config.js (auto-detected if omitted)
18 * --no-tailwind Skip Tailwind CDN injection
19 * --index-css Path to main CSS file
20 * --css-files Additional CSS files (repeatable)
21 * --extra-css Path to index.html to extract <style>/<link> from
22 * --html-class Class(es) for <html> element (e.g., "dark")
23 * --outdir Output directory (default: .stitch)
24 * --exclude-pattern Literal string to exclude from body HTML
25 * --concurrency Max concurrent image fetches (default: 6)
26 * --timeout HTTP request timeout in ms (default: 15000)
27 * --json Output machine-readable JSON stats
28 */
29
30 import * as parser from '@babel/parser' ;
31 import traverse from '@babel/traverse' ;
32 import generate from '@babel/generator' ;
33 import fs from 'node:fs' ;
34 import path from 'node:path' ;
35 import https from 'node:https' ;
36 import http from 'node:http' ;
37 import type { Node } from '@babel/types' ;
38
39 // ---------------------------------------------------------------------------
40 // Types
41 // ---------------------------------------------------------------------------
42 interface Opts {
43 pages : string [];
44 tailwindConfig : string | null ;
45 noTailwind : boolean ;
46 indexCss : string | null ;
47 cssFiles : string [];
48 extraCss : string | null ;
49 htmlClass : string | null ;
50 outdir : string ;
51 excludePattern : RegExp | null ;
52 concurrency : number ;
53 timeout : number ;
54 json : boolean ;
55 }
56
57 interface CssUrlRef {
58 url : string ;
59 fullMatch : string ;
60 start : number ;
61 end : number ;
62 }
63
64 interface PageStats {
65 src : string ;
66 dst : string ;
67 sizeBytes : number ;
68 imagesEmbedded : number ;
69 }
70
71 interface AllStats {
72 pages : PageStats [];
73 totalImages : number ;
74 durationMs : number ;
75 warnings : string [];
76 }
77
78 // ---------------------------------------------------------------------------
79 // Argument parsing
80 // ---------------------------------------------------------------------------
81 // Escape all regex metacharacters in user input so it is treated as a literal
82 // string match when used in new RegExp(). Prevents regex injection (ReDoS).
83 function escapeRegExp ( str : string ) : string {
84 return str. replace ( / [.*+?^${}()|[ \]\\ ] / g , ' \\ $&' );
85 }
86
87 function parseArgs () : Opts {
88 const args = process.argv. slice ( 2 );
89 const opts : Opts = {
90 pages: [],
91 tailwindConfig: null ,
92 noTailwind: false ,
93 indexCss: null ,
94 cssFiles: [],
95 extraCss: null ,
96 htmlClass: null ,
97 outdir: '.stitch' ,
98 excludePattern: null ,
99 concurrency: 6 ,
100 timeout: 15000 ,
101 json: false ,
102 };
103
104 for ( let i = 0 ; i < args. length ; i ++ ) {
105 switch (args[i]) {
106 case '--page' : opts.pages. push (args[ ++ i]); break ;
107 case '--tailwind-config' : opts.tailwindConfig = args[ ++ i]; break ;
108 case '--no-tailwind' : opts.noTailwind = true ; break ;
109 case '--index-css' : opts.indexCss = args[ ++ i]; break ;
110 case '--css-files' : opts.cssFiles. push (args[ ++ i]); break ;
111 case '--extra-css' : opts.extraCss = args[ ++ i]; break ;
112 case '--html-class' : opts.htmlClass = args[ ++ i]; break ;
113 case '--outdir' : opts.outdir = args[ ++ i]; break ;
114 case '--exclude-pattern' : {
115 const rawPattern = args[ ++ i];
116 // Escape metacharacters so user input is treated as a literal string
117 opts.excludePattern = new RegExp ( escapeRegExp (rawPattern), 'gs' );
118 break ;
119 }
120 case '--concurrency' : opts.concurrency = parseInt (args[ ++ i], 10 ); break ;
121 case '--timeout' : opts.timeout = parseInt (args[ ++ i], 10 ); break ;
122 case '--json' : opts.json = true ; break ;
123 case '--help' :
124 console. log ( `
125 Usage: npx tsx extract_inline_html.ts --page <spec> [options]
126
127 Options:
128 --page src_file:dst_filename:title (repeatable)
129 --tailwind-config Path to tailwind.config.js (auto-detected)
130 --no-tailwind Skip Tailwind CDN injection
131 --index-css Path to main CSS file
132 --css-files Additional CSS files (repeatable)
133 --extra-css Path to index.html for <style>/<link> extraction
134 --html-class Class for <html> element (e.g., "dark")
135 --outdir Output directory (default: .stitch)
136 --exclude-pattern Literal string to exclude from body
137 --concurrency Max concurrent image fetches (default: 6)
138 --timeout HTTP request timeout in ms (default: 15000)
139 --json Output machine-readable JSON stats
140 ` );
141 process. exit ( 0 );
142 default :
143 console. error ( `Unknown argument: ${ args [ i ] }` );
144 process. exit ( 1 );
145 }
146 }
147
148 return opts;
149 }
150
151 // ---------------------------------------------------------------------------
152 // Input validation
153 // ---------------------------------------------------------------------------
154 function validateOpts ( opts : Opts ) : void {
155 const errors : string [] = [];
156
157 if (opts.pages. length === 0 ) {
158 errors. push ( 'No pages specified. Use --page src:dst:title' );
159 }
160
161 for ( const spec of opts.pages) {
162 const parts = spec. split ( ':' );
163 if (parts. length !== 3 ) {
164 errors. push ( `Invalid page spec '${ spec }'. Must be src:dst:title` );
165 } else {
166 const [ src ] = parts;
167 if ( ! fs. existsSync (src)) {
168 errors. push ( `Source file not found: ${ src }` );
169 }
170 }
171 }
172
173 if (opts.indexCss && ! fs. existsSync (opts.indexCss)) {
174 errors. push ( `CSS file not found: ${ opts . indexCss }` );
175 }
176
177 for ( const f of opts.cssFiles) {
178 if ( ! fs. existsSync (f)) {
179 errors. push ( `CSS file not found: ${ f }` );
180 }
181 }
182
183 if (opts.extraCss && ! fs. existsSync (opts.extraCss)) {
184 errors. push ( `Extra CSS file not found: ${ opts . extraCss }` );
185 }
186
187 if ( isNaN (opts.concurrency) || opts.concurrency < 1 || opts.concurrency > 20 ) {
188 errors. push ( '--concurrency must be between 1 and 20' );
189 }
190
191 if ( isNaN (opts.timeout) || opts.timeout < 1000 ) {
192 errors. push ( '--timeout must be at least 1000ms' );
193 }
194
195 if (errors. length > 0 ) {
196 console. error ( '❌ Validation errors:' );
197 errors. forEach (( e ) => console. error ( ` • ${ e }` ));
198 process. exit ( 1 );
199 }
200 }
201
202 // ---------------------------------------------------------------------------
203 // Concurrency limiter
204 // ---------------------------------------------------------------------------
205 function createLimiter ( concurrency : number ) : < T >( fn : () => Promise < T >) => Promise < T > {
206 let active = 0 ;
207 const queue : Array <() => void > = [];
208
209 return function limit < T >( fn : () => Promise < T >) : Promise < T > {
210 return new Promise (( resolve , reject ) => {
211 const run = async () => {
212 active ++ ;
213 try {
214 resolve ( await fn ());
215 } catch (e) {
216 reject (e);
217 } finally {
218 active -- ;
219 if (queue. length > 0 ) queue. shift () ! ();
220 }
221 };
222
223 if (active < concurrency) {
224 run ();
225 } else {
226 queue. push (run);
227 }
228 });
229 };
230 }
231
232 // ---------------------------------------------------------------------------
233 // Image embedding (with concurrency, redirect-loop protection, timeouts)
234 // ---------------------------------------------------------------------------
235 const imgCache = new Map < string , string >();
236 const MAX_REDIRECTS = 5 ;
237
238 function isImageUrl ( url : string ) : boolean {
239 const skip = [ 'cdn.tailwindcss.com' , 'fonts.googleapis.com' , '.js' , '.css' ];
240 return ! skip. some (( s ) => url. includes (s));
241 }
242
243
244
245 /**
246 * Validate that a URL is safe for outbound requests (SSRF protection).
247 * Blocks private/internal network addresses and non-HTTP protocols.
248 * URLs parsed from HTML files could be attacker-controlled, so we must
249 * ensure they only target public internet hosts.
250 */
251 function isSafeUrl ( parsed : URL ) : boolean {
252 // Only allow http and https protocols
253 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:' ) {
254 return false ;
255 }
256
257 const hostname = parsed.hostname. toLowerCase ();
258
259 // Block localhost variants
260 if (hostname === 'localhost' || hostname === '[::1]' ) {
261 return false ;
262 }
263
264 // Block private/reserved IPv4 ranges
265 const ipv4Match = hostname. match ( / ^ ( \d {1,3} ) \. ( \d {1,3} ) \. ( \d {1,3} ) \. ( \d {1,3} ) $ / );
266 if (ipv4Match) {
267 const [, a , b ] = ipv4Match. map (Number);
268 if (
269 a === 127 || // 127.0.0.0/8 (loopback)
270 a === 10 || // 10.0.0.0/8 (private)
271 a === 0 || // 0.0.0.0/8 (unspecified)
272 (a === 172 && b >= 16 && b <= 31 ) || // 172.16.0.0/12 (private)
273 (a === 192 && b === 168 ) || // 192.168.0.0/16 (private)
274 (a === 169 && b === 254 ) // 169.254.0.0/16 (link-local)
275 ) {
276 return false ;
277 }
278 }
279
280 return true ;
281 }
282
283 // Intentional outbound requests: this function fetches remote images
284 // referenced in HTML source files and embeds them as base64 data URIs to
285 // produce self-contained HTML snapshots. URLs are validated by isSafeUrl()
286 // to block SSRF against private/internal networks. [CodeQL js/file-access-to-http]
287 function fetchAndEncode ( url : string , timeout : number , redirectCount = 0 ) : Promise < string > {
288 if (imgCache. has (url)) return Promise . resolve (imgCache. get (url) ! );
289 if ( ! isImageUrl (url)) {
290 imgCache. set (url, url);
291 return Promise . resolve (url);
292 }
293
294 // Redirect-loop protection
295 if (redirectCount >= MAX_REDIRECTS ) {
296 const fallback =
297 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' ;
298 console. warn ( ` WARN: Too many redirects (${ MAX_REDIRECTS }) <- ${ url . slice ( 0 , 70 ). replace ( / \n | \r / g , '' ) }...` );
299 imgCache. set (url, fallback);
300 return Promise . resolve (fallback);
301 }
302
303 return new Promise (( resolve ) => {
304 let parsedUrl : URL ;
305 try {
306 parsedUrl = new URL (url);
307 } catch {
308 const fallback =
309 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' ;
310 console. warn ( ` WARN: Invalid URL: ${ url . slice ( 0 , 70 ). replace ( / \n | \r / g , '' ) }...` );
311 imgCache. set (url, fallback);
312 resolve (fallback);
313 return ;
314 }
315
316 // SSRF protection: block requests to private/internal networks
317 if ( ! isSafeUrl (parsedUrl)) {
318 const fallback =
319 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' ;
320 console. warn ( ` WARN: Blocked request to non-public URL: ${ url . slice ( 0 , 70 ). replace ( / \n | \r / g , '' ) }...` );
321 imgCache. set (url, fallback);
322 resolve (fallback);
323 return ;
324 }
325
326 const client = parsedUrl.protocol === 'https:' ? https : http;
327 const req = client. get (
328 url,
329 {
330 headers: { 'User-Agent' : 'Mozilla/5.0 (compatible; SnapshotBot/2.0)' },
331 timeout,
332 },
333 ( resp ) => {
334 if (
335 resp.statusCode ! >= 300 &&
336 resp.statusCode ! < 400 &&
337 resp.headers.location
338 ) {
339 // Resolve relative redirect URLs
340 let redirectUrl : string ;
341 try {
342 redirectUrl = new URL (resp.headers.location, url).href;
343 } catch {
344 redirectUrl = resp.headers.location;
345 }
346 // Consume response body to free the socket
347 resp. resume ();
348 fetchAndEncode (redirectUrl, timeout, redirectCount + 1 ). then (resolve);
349 return ;
350 }
351
352 if (resp.statusCode ! >= 400 ) {
353 const fallback =
354 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' ;
355 console. warn (
356 ` WARN: HTTP ${ resp . statusCode } <- ${ url . slice ( 0 , 70 ). replace ( / \n | \r / g , '' ) }...` ,
357 );
358 resp. resume ();
359 imgCache. set (url, fallback);
360 resolve (fallback);
361 return ;
362 }
363
364 const chunks : Buffer [] = [];
365 resp. on ( 'data' , ( d : Buffer ) => chunks. push (d));
366 resp. on ( 'end' , () => {
367 const buf = Buffer. concat (chunks);
368 const ct = resp.headers[ 'content-type' ] || 'image/jpeg' ;
369 const result = `data:${ ct };base64,${ buf . toString ( 'base64' ) }` ;
370 imgCache. set (url, result);
371 console. log (
372 ` Embedded ${ buf . length . toLocaleString () } bytes <- ${ url . slice ( 0 , 70 ). replace ( / \n | \r / g , '' ) }...` ,
373 );
374 resolve (result);
375 });
376 resp. on ( 'error' , ( e : Error ) => {
377 const fallback =
378 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' ;
379 console. warn ( ` WARN: Stream error: ${ e . message . replace ( / \n | \r / g , '' ) } <- ${ url . slice ( 0 , 70 ). replace ( / \n | \r / g , '' ) }...` );
380 imgCache. set (url, fallback);
381 resolve (fallback);
382 });
383 },
384 );
385
386 req. on ( 'error' , ( e : Error ) => {
387 const fallback =
388 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' ;
389 console. warn ( ` WARN: ${ e . message . replace ( / \n | \r / g , '' ) } <- ${ url . slice ( 0 , 70 ). replace ( / \n | \r / g , '' ) }...` );
390 imgCache. set (url, fallback);
391 resolve (fallback);
392 });
393
394 req. on ( 'timeout' , () => {
395 req. destroy ();
396 const fallback =
397 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' ;
398 console. warn ( ` WARN: Timeout after ${ timeout }ms <- ${ url . slice ( 0 , 70 ). replace ( / \n | \r / g , '' ) }...` );
399 imgCache. set (url, fallback);
400 resolve (fallback);
401 });
402 });
403 }
404
405 // ---------------------------------------------------------------------------
406 // Robust CSS url() parser — character-by-character (no regex)
407 // ---------------------------------------------------------------------------
408 function extractCssUrls ( text : string ) : CssUrlRef [] {
409 const results : CssUrlRef [] = [];
410 let i = 0 ;
411 const len = text. length ;
412
413 while (i < len) {
414 // Look for 'url(' — case insensitive
415 if (
416 i + 3 < len &&
417 text[i]. toLowerCase () === 'u' &&
418 text[i + 1 ]. toLowerCase () === 'r' &&
419 text[i + 2 ]. toLowerCase () === 'l' &&
420 text[i + 3 ] === '('
421 ) {
422 const urlStart = i;
423 i += 4 ;
424
425 // Skip whitespace
426 while (i < len && (text[i] === ' ' || text[i] === ' \t ' || text[i] === ' \n ' || text[i] === ' \r ' )) i ++ ;
427
428 // Check for quote
429 let quote : string | null = null ;
430 if (i < len && (text[i] === '"' || text[i] === "'" )) {
431 quote = text[i];
432 i ++ ;
433 }
434
435 // Read the URL value
436 let url = '' ;
437 if (quote) {
438 while (i < len && text[i] !== quote) {
439 if (text[i] === ' \\ ' && i + 1 < len) {
440 i ++ ;
441 url += text[i];
442 } else {
443 url += text[i];
444 }
445 i ++ ;
446 }
447 if (i < len) i ++ ; // closing quote
448 } else {
449 while (i < len && text[i] !== ')' && text[i] !== ' ' && text[i] !== ' \t ' && text[i] !== ' \n ' ) {
450 url += text[i];
451 i ++ ;
452 }
453 }
454
455 // Skip trailing whitespace before ')'
456 while (i < len && (text[i] === ' ' || text[i] === ' \t ' || text[i] === ' \n ' || text[i] === ' \r ' )) i ++ ;
457
458 if (i < len && text[i] === ')' ) {
459 const fullMatch = text. substring (urlStart, i + 1 );
460 results. push ({ url: url. trim (), fullMatch, start: urlStart, end: i + 1 });
461 i ++ ;
462 } else {
463 i = urlStart + 1 ;
464 }
465 } else {
466 i ++ ;
467 }
468 }
469
470 return results;
471 }
472
473 /**
474 * Replace CSS url() references using pre-computed positions.
475 * Replaces from end-to-start to preserve earlier indices.
476 */
477 function replaceCssUrlsInText (
478 text : string ,
479 replacements : Array <{ start : number ; end : number ; dataUri : string }>,
480 ) : string {
481 const sorted = [ ... replacements]. sort (( a , b ) => b.start - a.start);
482 for ( const r of sorted) {
483 text = text. substring ( 0 , r.start) + "url('" + r.dataUri + "')" + text. substring (r.end);
484 }
485 return text;
486 }
487
488 // ---------------------------------------------------------------------------
489 // Image & CSS url() embedding with concurrency
490 // ---------------------------------------------------------------------------
491 async function embedImages ( html : string , concurrency : number , timeout : number ) : Promise < string > {
492 const limit = createLimiter (concurrency);
493
494 // --- Embed <img src="https://..."> ---
495 const srcMatches = [ ... html. matchAll ( /src="(https ? : \/\/ [ ^ "] + )"/ g )];
496 const srcImageMatches = srcMatches. filter (( m ) => isImageUrl (m[ 1 ]));
497
498 // Prefetch all URLs concurrently
499 await Promise . all (
500 srcImageMatches. map (( m ) => limit (() => fetchAndEncode (m[ 1 ], timeout))),
501 );
502
503 // Replace (cache is now warm — synchronous lookups)
504 for ( const m of srcImageMatches) {
505 const encoded = imgCache. get (m[ 1 ]);
506 if (encoded && encoded !== m[ 1 ]) {
507 html = html. replace (m[ 0 ], `src="${ encoded }"` );
508 }
509 }
510
511 // --- Embed CSS url("https://...") using robust parser ---
512 const cssUrlRefs = extractCssUrls (html);
513 const httpUrlRefs = cssUrlRefs. filter (
514 ( ref ) =>
515 (ref.url. startsWith ( 'http://' ) || ref.url. startsWith ( 'https://' )) &&
516 isImageUrl (ref.url),
517 );
518
519 // Prefetch all CSS url() references concurrently
520 await Promise . all (
521 httpUrlRefs. map (( ref ) => limit (() => fetchAndEncode (ref.url, timeout))),
522 );
523
524 // Replace from end-to-start to preserve indices
525 const replacements : Array <{ start : number ; end : number ; dataUri : string }> = [];
526 for ( const ref of httpUrlRefs) {
527 const encoded = imgCache. get (ref.url);
528 if (encoded && encoded !== ref.url) {
529 replacements. push ({ start: ref.start, end: ref.end, dataUri: encoded });
530 }
531 }
532 if (replacements. length > 0 ) {
533 html = replaceCssUrlsInText (html, replacements);
534 }
535
536 // --- Embed <video poster="https://..."> ---
537 const posterMatches = [ ... html. matchAll ( /poster="(https ? : \/\/ [ ^ "] + )"/ g )];
538 await Promise . all (
539 posterMatches. map (( m ) => limit (() => fetchAndEncode (m[ 1 ], timeout))),
540 );
541 for ( const m of posterMatches) {
542 const encoded = imgCache. get (m[ 1 ]);
543 if (encoded && encoded !== m[ 1 ]) {
544 html = html. replace (m[ 0 ], `poster="${ encoded }"` );
545 }
546 }
547
548 return html;
549 }
550
551 // ---------------------------------------------------------------------------
552 // JSX → HTML conversion using Babel AST
553 // ---------------------------------------------------------------------------
554
555 // Convert camelCase to kebab-case
556 function camelToKebab ( str : string ) : string {
557 return str. replace ( /( [a-z] )( [A-Z] )/ g , '$1-$2' ). toLowerCase ();
558 }
559
560 // SVG attribute mapping
561 const SVG_ATTRS : Record < string , string > = {
562 strokeLinecap: 'stroke-linecap' , strokeLinejoin: 'stroke-linejoin' ,
563 strokeWidth: 'stroke-width' , strokeDasharray: 'stroke-dasharray' ,
564 strokeDashoffset: 'stroke-dashoffset' , strokeMiterlimit: 'stroke-miterlimit' ,
565 strokeOpacity: 'stroke-opacity' , stopColor: 'stop-color' ,
566 stopOpacity: 'stop-opacity' , fillRule: 'fill-rule' , fillOpacity: 'fill-opacity' ,
567 clipRule: 'clip-rule' , clipPath: 'clip-path' , viewBox: 'viewBox' ,
568 xlinkHref: 'xlink:href' , xmlSpace: 'xml:space' , xmlLang: 'xml:lang' ,
569 };
570
571 // React attribute mapping
572 const REACT_ATTRS : Record < string , string > = {
573 className: 'class' , htmlFor: 'for' , defaultValue: 'value' ,
574 defaultChecked: 'checked' , tabIndex: 'tabindex' , autoFocus: 'autofocus' ,
575 autoComplete: 'autocomplete' , crossOrigin: 'crossorigin' ,
576 };
577
578 // HTML void elements (self-closing)
579 const VOID_ELEMENTS = new Set ([
580 'area' , 'base' , 'br' , 'col' , 'embed' , 'hr' , 'img' , 'input' ,
581 'link' , 'meta' , 'param' , 'source' , 'track' , 'wbr' ,
582 ]);
583
584 function jsxToHtml ( jsxSource : string ) : string | null {
585 let ast;
586 try {
587 ast = parser. parse (jsxSource, {
588 sourceType: 'module' ,
589 plugins: [ 'jsx' , 'typescript' , 'optionalChaining' , 'nullishCoalescingOperator' ],
590 });
591 } catch ( e : unknown ) {
592 console. error ( ` Babel parse error: ${ ( e as Error ). message }` );
593 return null ;
594 }
595
596 // Strategy 1: Prefer the JSX return inside the default-exported function,
597 // since that's the main component in the vast majority of React files.
598 let returnedJSX : Node | null = null ;
599
600 traverse (ast, {
601 ExportDefaultDeclaration ( path ) {
602 const decl = path.node.declaration;
603
604 // Handle: export default function Component() { return <JSX/> }
605 if (decl.type === 'FunctionDeclaration' ) {
606 path. traverse ({
607 ReturnStatement ( retPath ) {
608 const arg = retPath.node.argument;
609 if (arg && (arg.type === 'JSXElement' || arg.type === 'JSXFragment' )) {
610 returnedJSX = arg;
611 retPath. stop ();
612 }
613 },
614 });
615 if (returnedJSX) path. stop ();
616 }
617
618 // Handle: export default () => <JSX/> or export default () => { return <JSX/> }
619 if (decl.type === 'ArrowFunctionExpression' ) {
620 if (decl.body.type === 'JSXElement' || decl.body.type === 'JSXFragment' ) {
621 returnedJSX = decl.body;
622 path. stop ();
623 } else {
624 path. traverse ({
625 ReturnStatement ( retPath ) {
626 const arg = retPath.node.argument;
627 if (arg && (arg.type === 'JSXElement' || arg.type === 'JSXFragment' )) {
628 returnedJSX = arg;
629 retPath. stop ();
630 }
631 },
632 });
633 if (returnedJSX) path. stop ();
634 }
635 }
636 },
637 });
638
639 // Strategy 2: Fallback — find the return with the largest JSX tree.
640 // Covers files that use `export default ComponentName` (identifier) at the
641 // bottom, or files without any default export at all.
642 if ( ! returnedJSX) {
643 let maxLen = - 1 ;
644 traverse (ast, {
645 ReturnStatement ( path ) {
646 const arg = path.node.argument;
647 if (arg && (arg.type === 'JSXElement' || arg.type === 'JSXFragment' )) {
648 const len = (arg.end || 0 ) - (arg.start || 0 );
649 if (len > maxLen) {
650 maxLen = len;
651 returnedJSX = arg;
652 }
653 }
654 },
655 });
656 }
657
658 if ( ! returnedJSX) {
659 console. error ( ' No JSX return statement found' );
660 return null ;
661 }
662
663 return renderNode (returnedJSX);
664 }
665
666 function renderNode ( node : any ) : string {
667 if ( ! node) return '' ;
668
669 switch (node.type) {
670 case 'JSXElement' :
671 return renderElement (node);
672 case 'JSXFragment' :
673 return node.children. map (renderNode). join ( '' );
674 case 'JSXText' :
675 return node.value;
676 case 'JSXExpressionContainer' :
677 return renderExpression (node.expression);
678 case 'StringLiteral' :
679 return node.value;
680 default :
681 return '' ;
682 }
683 }
684
685 function renderExpression ( expr : any ) : string {
686 if ( ! expr) return '' ;
687 switch (expr.type) {
688 case 'JSXEmptyExpression' :
689 return '' ;
690 case 'StringLiteral' :
691 return expr.value;
692 case 'NumericLiteral' :
693 return String (expr.value);
694 case 'TemplateLiteral' :
695 // Flatten template literals — just join the quasis
696 return expr.quasis. map (( q : any ) => q.value.raw). join ( '' );
697 default :
698 console. warn ( ` WARN: Unhandled JSX expression of type "${ expr . type }" inside child node.` );
699 return '' ;
700 }
701 }
702
703 function renderElement ( node : any ) : string {
704 const tagName = getTagName (node.openingElement);
705
706 // Skip <Link> — render children in a <div>
707 if (tagName === 'Link' ) {
708 const attrs = renderAttributes (node.openingElement.attributes, 'div' );
709 const children = node.children. map (renderNode). join ( '' );
710 return `<div${ attrs }>${ children }</div>` ;
711 }
712
713 // Skip unknown components (capitalized) — just render children
714 if (tagName[ 0 ] === tagName[ 0 ]. toUpperCase () && tagName[ 0 ] !== tagName[ 0 ]. toLowerCase ()) {
715 return node.children. map (renderNode). join ( '' );
716 }
717
718 const attrs = renderAttributes (node.openingElement.attributes, tagName);
719 const selfClosing = node.openingElement.selfClosing;
720
721 if (selfClosing && VOID_ELEMENTS . has (tagName)) {
722 return `<${ tagName }${ attrs }/>` ;
723 }
724
725 const children = node.children. map (renderNode). join ( '' );
726
727 if (selfClosing && ! VOID_ELEMENTS . has (tagName)) {
728 return `<${ tagName }${ attrs }></${ tagName }>` ;
729 }
730
731 return `<${ tagName }${ attrs }>${ children }</${ tagName }>` ;
732 }
733
734 function getTagName ( openingElement : any ) : string {
735 if (openingElement.name.type === 'JSXIdentifier' ) {
736 return openingElement.name.name;
737 }
738 if (openingElement.name.type === 'JSXMemberExpression' ) {
739 return `${ openingElement . name . object . name }.${ openingElement . name . property . name }` ;
740 }
741 return 'div' ;
742 }
743
744 function renderAttributes ( attrs : any [], tagName : string ) : string {
745 if ( ! attrs || attrs. length === 0 ) return '' ;
746
747 const parts : string [] = [];
748 for ( const attr of attrs) {
749 if (attr.type === 'JSXSpreadAttribute' ) continue ; // Skip {...props}
750
751 let name : string = attr.name?.name || '' ;
752
753 // Skip event handlers and React-specific props
754 if (name. startsWith ( 'on' ) && name[ 2 ] === name[ 2 ]?. toUpperCase ()) continue ;
755 if ([ 'key' , 'ref' , 'dangerouslySetInnerHTML' ]. includes (name)) continue ;
756
757 // Map React attributes
758 if ( REACT_ATTRS [name]) name = REACT_ATTRS [name];
759 else if ( SVG_ATTRS [name]) name = SVG_ATTRS [name];
760
761 // Handle value
762 if ( ! attr.value) {
763 // Boolean attribute like `checked`
764 parts. push (name);
765 continue ;
766 }
767
768 if (attr.value.type === 'StringLiteral' ) {
769 // Skip `to` attribute from Link (already handled)
770 if (name === 'to' ) continue ;
771 parts. push ( `${ name }="${ attr . value . value }"` );
772 } else if (attr.value.type === 'JSXExpressionContainer' ) {
773 const expr = attr.value.expression;
774 if (name === 'style' && expr.type === 'ObjectExpression' ) {
775 // Convert style={{...}} to style="..."
776 const styleStr = renderStyleObject (expr);
777 if (styleStr) parts. push ( `style="${ styleStr }"` );
778 } else if (expr.type === 'StringLiteral' ) {
779 parts. push ( `${ name }="${ expr . value }"` );
780 } else if (expr.type === 'NumericLiteral' ) {
781 parts. push ( `${ name }="${ expr . value }"` );
782 } else if (expr.type === 'TemplateLiteral' ) {
783 const val = expr.quasis. map (( q : any ) => q.value.raw). join ( '' );
784 parts. push ( `${ name }="${ val }"` );
785 } else {
786 console. warn ( ` WARN: Unhandled JSX expression of type "${ expr . type }" inside attribute "${ name }".` );
787 }
788 }
789 }
790
791 return parts. length > 0 ? ' ' + parts. join ( ' ' ) : '' ;
792 }
793
794 function renderStyleObject ( objExpr : any ) : string {
795 const pairs : string [] = [];
796 for ( const prop of objExpr.properties) {
797 if (prop.type !== 'ObjectProperty' ) continue ;
798 const key = prop.key.name || prop.key.value;
799 if ( ! key) continue ;
800 const cssKey = camelToKebab (key);
801
802 let val : string | undefined ;
803 if (prop.value.type === 'StringLiteral' ) val = prop.value.value;
804 else if (prop.value.type === 'NumericLiteral' ) val = prop.value.value === 0 ? '0' : `${ prop . value . value }px` ;
805 else if (prop.value.type === 'TemplateLiteral' ) val = prop.value.quasis. map (( q : any ) => q.value.raw). join ( '' );
806 else continue ;
807
808 pairs. push ( `${ cssKey }: ${ val }` );
809 }
810 return pairs. join ( '; ' );
811 }
812
813 // ---------------------------------------------------------------------------
814 // Tailwind & CSS helpers
815 // ---------------------------------------------------------------------------
816
817 function autoDetectTailwind ( dir = '.' ) : string | null {
818 for ( const name of [ 'tailwind.config.js' , 'tailwind.config.ts' , 'tailwind.config.mjs' , 'tailwind.config.cjs' ]) {
819 const p = path. join (dir, name);
820 if (fs. existsSync (p)) return p;
821 }
822 return null ;
823 }
824
825 function readCssFile ( filePath : string | null ) : { imports : string []; css : string ; hasApply : boolean } {
826 if ( ! filePath || ! fs. existsSync (filePath)) return { imports: [], css: '' , hasApply: false };
827 const content = fs. readFileSync (filePath, 'utf-8' );
828 const imports : string [] = [];
829 const cssLines : string [] = [];
830 for ( const line of content. split ( ' \n ' )) {
831 if (line. trim (). startsWith ( '@import' )) imports. push (line. trim ());
832 else if ( ! line. trim (). startsWith ( '@tailwind' )) cssLines. push (line);
833 }
834 const css = cssLines. join ( ' \n ' );
835 return { imports, css, hasApply: /@apply \s + / . test (css) };
836 }
837
838 function extractFromHtml ( htmlPath : string | null ) : { styles : string ; links : string [] } {
839 if ( ! htmlPath || ! fs. existsSync (htmlPath)) return { styles: '' , links: [] };
840 const html = fs. readFileSync (htmlPath, 'utf-8' );
841 const styles : string [] = [];
842 const links : string [] = [];
843 // Extract <style> blocks
844 for ( const m of html. matchAll ( /<style [ ^ >] * >( . *? )< \/ style>/ gs )) {
845 styles. push (m[ 1 ]. trim ());
846 }
847 // Extract stylesheet <link> tags with http URLs
848 for ( const m of html. matchAll ( /<link [ ^ >] + href="( [ ^ "] + )" [ ^ >] * rel="stylesheet" [ ^ >] * \/ ? > | <link [ ^ >] + rel="stylesheet" [ ^ >] * href="( [ ^ "] + )" [ ^ >] * \/ ? >/ g )) {
849 const href = m[ 1 ] || m[ 2 ];
850 if (href?. startsWith ( 'http' )) links. push (href);
851 }
852 return { styles: styles. join ( ' \n ' ), links };
853 }
854
855 // ---------------------------------------------------------------------------
856 // Robust @import URL extraction using parser (not regex)
857 // ---------------------------------------------------------------------------
858 function extractImportUrl ( importLine : string ) : string | null {
859 // Handle: @import url("..."), @import url('...'), @import url(...)
860 const urlRefs = extractCssUrls (importLine);
861 if (urlRefs. length > 0 ) return urlRefs[ 0 ].url;
862
863 // Handle: @import "..." and @import '...'
864 const quoteMatch = importLine. match ( /@import \s + ['"] ( [ ^ '"] + ) ['"] / );
865 if (quoteMatch) return quoteMatch[ 1 ];
866
867 return null ;
868 }
869
870 // ---------------------------------------------------------------------------
871 // Build head template
872 // ---------------------------------------------------------------------------
873 function buildHead ( opts : Opts ) : string {
874 let useTailwind = ! opts.noTailwind;
875 let tailwindConfig = opts.tailwindConfig;
876
877 if (useTailwind && ! tailwindConfig) {
878 tailwindConfig = autoDetectTailwind ();
879 if (tailwindConfig) console. log ( `Auto-detected Tailwind config: ${ tailwindConfig }` );
880 else { useTailwind = false ; console. log ( 'No Tailwind config found — skipping CDN.' ); }
881 }
882
883 // Read CSS
884 const indexCss = readCssFile (opts.indexCss);
885 let hasApply = indexCss.hasApply;
886
887 let extraCssContent = '' ;
888 for ( const f of opts.cssFiles) {
889 if (fs. existsSync (f)) {
890 const content = fs. readFileSync (f, 'utf-8' );
891 extraCssContent += `/* --- ${ path . basename ( f ) } --- */ \n ${ content } \n ` ;
892 if ( /@apply \s + / . test (content)) hasApply = true ;
893 console. log ( `Included CSS: ${ f }` );
894 }
895 }
896
897 const htmlExtra = extractFromHtml (opts.extraCss);
898
899 // Build head
900 const htmlAttrs = opts.htmlClass ? ` lang="en" class="${ opts . htmlClass }"` : ' lang="en"' ;
901 let head = `<!DOCTYPE html> \n <html${ htmlAttrs }><head> \n <meta charset="utf-8"/> \n <meta content="width=device-width, initial-scale=1.0" name="viewport"/> \n ` ;
902
903 if (useTailwind) {
904 head += '<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> \n ' ;
905 }
906
907 // @import → <link> (using robust parser)
908 for ( const imp of indexCss.imports) {
909 const href = extractImportUrl (imp);
910 if (href) head += `<link href="${ href }" rel="stylesheet"/> \n ` ;
911 }
912
913 // Extra font links from index.html
914 for ( const href of htmlExtra.links) {
915 head += `<link href="${ href }" rel="stylesheet"/> \n ` ;
916 }
917
918 // Tailwind config
919 if (useTailwind && tailwindConfig && fs. existsSync (tailwindConfig)) {
920 let tw = fs. readFileSync (tailwindConfig, 'utf-8' );
921 tw = tw. replace ( /export \s + default \s + / , 'tailwind.config = ' );
922 tw = tw. replace ( /module \. exports \s * = \s * / , 'tailwind.config = ' );
923 tw = tw. replace ( / . * require \( ['"] tailwindcss \/ colors ['"] \) . * \n ? / g , '' );
924 head += `<script> \n ${ tw } \n </script> \n ` ;
925 }
926
927 // Combined CSS
928 const allCss = `body { \n min-height: 100dvh; \n } \n ${ indexCss . css } \n ${ extraCssContent } \n ${ htmlExtra . styles }` ;
929 const styleType = hasApply && useTailwind ? ' type="text/tailwindcss"' : '' ;
930 if (hasApply && useTailwind) {
931 console. log ( 'Detected @apply — using <style type="text/tailwindcss">' );
932 }
933 head += `<style${ styleType }> \n ${ allCss }</style> \n </head> \n ` ;
934
935 return head;
936 }
937
938 // ---------------------------------------------------------------------------
939 // Main
940 // ---------------------------------------------------------------------------
941 async function main () : Promise < void > {
942 const opts = parseArgs ();
943 validateOpts (opts);
944
945 const startTime = Date. now ();
946 const head = buildHead (opts);
947 const stats : AllStats = {
948 pages: [],
949 totalImages: 0 ,
950 durationMs: 0 ,
951 warnings: [],
952 };
953
954 fs. mkdirSync (opts.outdir, { recursive: true });
955
956 for ( const spec of opts.pages) {
957 const parts = spec. split ( ':' );
958 const [ src , dstName , title ] = parts;
959 const dst = path. join (opts.outdir, dstName);
960
961 console. log ( ` \n ${'=' . repeat ( 60 ) }` );
962 console. log ( `Converting ${ src } -> ${ dstName }...` );
963 console. log ( `${'=' . repeat ( 60 ) }` );
964
965 const jsx = fs. readFileSync (src, 'utf-8' );
966 let body = jsxToHtml (jsx);
967
968 if ( ! body) {
969 const msg = `Failed to parse JSX from ${ src }` ;
970 console. error ( ` ${ msg }` );
971 stats.warnings. push (msg);
972 continue ;
973 }
974
975 // Apply exclude pattern (pre-validated during argument parsing)
976 if (opts.excludePattern) {
977 body = body. replace (opts.excludePattern, '' );
978 }
979
980 // Extract body class from outer wrapper div
981 const outerMatch = body. match ( / ^ <div \s + class="( [ ^ "] * )" [ ^ >] * >( [\s\S] * )< \/ div> $ / );
982 let fullHtml : string ;
983 if (outerMatch) {
984 fullHtml = head. replace ( '{{title}}' , title) +
985 `<body class="${ outerMatch [ 1 ] }"> \n ${ outerMatch [ 2 ]. trim () } \n </body></html> \n ` ;
986 } else {
987 fullHtml = head. replace ( '{{title}}' , title) +
988 `<body> \n ${ body } \n </body></html> \n ` ;
989 }
990
991 // Embed remote images with concurrency
992 const cacheCountBefore = imgCache.size;
993 fullHtml = await embedImages (fullHtml, opts.concurrency, opts.timeout);
994 const imagesEmbedded = imgCache.size - cacheCountBefore;
995
996 fs. writeFileSync (dst, fullHtml, 'utf-8' );
997 const fileSize = fs. statSync (dst).size;
998 console. log ( `=> ${ dst } (${ fileSize . toLocaleString () } bytes)` );
999
1000 stats.pages. push ({
1001 src,
1002 dst,
1003 sizeBytes: fileSize,
1004 imagesEmbedded,
1005 });
1006 }
1007
1008 stats.totalImages = imgCache.size;
1009 stats.durationMs = Date. now () - startTime;
1010
1011 console. log (
1012 ` \n DONE: ${ imgCache . size } unique images embedded in ${ stats . durationMs }ms.` ,
1013 );
1014
1015 if (opts.json) {
1016 console. log ( ' \n --- JSON Stats ---' );
1017 console. log ( JSON . stringify (stats, null , 2 ));
1018 }
1019 }
1020
1021 main (). catch (( err : Error ) => {
1022 console. error ( '❌ Error:' , err.message);
1023 if (err.stack) console. error (err.stack);
1024 process. exit ( 1 );
1025 });