
Stitch::Extract Static Html
- 5k installs
- 7.8k repo stars
- Updated July 21, 2026
- google-labs-code/stitch-skills
Convert a running web app into a single self-contained HTML file with inlined CSS and base64 images.
About
This skill extracts fully rendered static HTML from any locally running web application by inlining all CSS stylesheets and converting images to base64 data URIs. It offers two strategies: Strategy A uses Puppeteer to snapshot the DOM with computed styles (recommended, zero setup), while Strategy B uses a browser subagent for pages requiring interaction before capture. The workflow involves starting the local dev server, confirming it renders correctly, then running the snapshot script with configurable viewport, wait times, and optional class/styling overrides. Useful for capturing specific UI states, creating shareable HTML versions, or preparing assets for Stitch integration without maintaining a live server. Puppeteer-based snapshot captures fully computed styles and inlines all CSS - no MockPage needed Automatically converts images and srcset entries to base64; removes broken URLs gracefully Framework-agnostic
- Puppeteer-based snapshot captures fully computed styles and inlines all CSS - no MockPage needed
- Automatically converts images and srcset entries to base64; removes broken URLs gracefully
- Framework-agnostic works with React, Vue, Next.js, Svelte, Storybook with framework-specific timing tweaks
- Browser subagent option for interactive pages requiring clicks, form fills, or navigation before capture
- Configurable via flags: viewport size, wait times, dark mode class, fixed element removal, full-height capture
Stitch::Extract Static Html by the numbers
- 4,981 all-time installs (skills.sh)
- +354 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #98 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
stitch::extract-static-html capabilities & compatibility
- Capabilities
- inline all link stylesheets to style blocks · convert img src and srcset to base64 data uris · remove script tags and dev overlays · resolve relative css url() paths before inlining · capture dark mode and custom html classes · remove fixed/sticky elements like banners
- Use cases
- ui design · web design · documentation
- Runs
- Runs locally
What stitch::extract-static-html says it does
Extract self-contained static HTML from a built web application or React components by inlining CSS and images.
npx skills add https://github.com/google-labs-code/stitch-skills --skill stitchextract-static-htmlAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5k |
|---|---|
| repo stars | ★ 7.8k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 21, 2026 |
| Repository | google-labs-code/stitch-skills ↗ |
What it does
Convert running web applications or React components into self-contained static HTML files with inlined CSS and base64 images for sharing or Stitch upload.
Who is it for?
Developers capturing UI states, preparing shareable HTML snapshots, exporting React component layouts, archiving page versions.
Skip if: Pages requiring continuous server interaction, complex client-side state management, authenticated-only content without bypass.
When should I use this skill?
User asks to save HTML, export a page, prepare Stitch assets, or capture a specific UI state from a running app.
What you get
A portable HTML file with no external dependencies, ready for sharing or Stitch integration.
- Self-contained .html file with inlined stylesheets and base64 images
- Optional: multiple HTML files for different routes or UI states
By the numbers
- Two extraction strategies: Puppeteer (recommended, highest fidelity) and Browser Subagent (interactive)
- Supports 6+ frameworks: React, Vue, Next.js, Nuxt, Svelte, SvelteKit, Storybook
Files
Extract Static HTML
Extract a self-contained static HTML file from any web application.
Which Strategy to Use
You MUST ask the user to choose which strategy to use before proceeding. Present the options clearly, recommend Strategy A as the preferred default, and provide a brief pros/cons summary for each option to help them make an informed decision.
| Strategy A (Puppeteer) | Strategy B (Browser Subagent) | |
|---|---|---|
| When | App runs locally, no auth wall | Need to interact with page first (click, fill forms) |
| Fidelity | Highest — computed styles resolved | High — rendered DOM |
| Setup | Zero — no mock needed | Zero — no mock needed |
| Framework | Any | Any |
| Output | Writes to file — no size limit | May truncate in agent context |
[!WARNING]
Checkpoint — User Confirmation Required.
You MUST ask the user which strategy they prefer before proceeding.
Present the comparison table above, recommend Strategy A as the default, and
wait for explicit approval. Do NOT make the decision yourself or proceed
until the user confirms.
***
Strategy A: Puppeteer Snapshot (Recommended)
Launches headless Chrome, captures the fully rendered DOM, and produces a self-contained HTML file with all CSS inlined and images as base64. Works with any framework — no MockPage.jsx needed.
Prerequisites
- App running locally (e.g.,
npm run dev) - Node.js with
puppeteeravailable (check:node -e "require('puppeteer')")
Workflow
1. Start the App and note the port.
[!WARNING]
Checkpoint — User Confirmation Required.
After starting the local server, you MUST pause and ask the user for
confirmation before running the snapshot script or launching a browser
subagent. Report the URL and port to the user so they can verify the app
is running and rendering correctly. Do NOT proceed to the snapshot
step until the user confirms.
2. Run the Snapshot Script:
npx tsx <SKILL_DIR>/scripts/snapshot.ts \
--url http://localhost:5173 \
--output .stitch/home.html \
--wait 20003. Multiple pages — run once per route:
npx tsx <SKILL_DIR>/scripts/snapshot.ts \
--url http://localhost:5173 --output .stitch/home.html --wait 2000
npx tsx <SKILL_DIR>/scripts/snapshot.ts \
--url http://localhost:5173/pricing --output .stitch/pricing.html --wait 2000
npx tsx <SKILL_DIR>/scripts/snapshot.ts \
--url http://localhost:5173/dashboard --output .stitch/dashboard.html --wait 2000 --html-class darkScript Flags
| Flag | Default | Description |
|---|---|---|
--url | (required) | URL to capture |
--output | (required) | Output file path |
--wait | 1000 | Extra wait (ms) after network idle. Increase for lazy-loading apps. |
--viewport | 1280x800 | Viewport size as WIDTHxHEIGHT |
--html-class | — | Class(es) for <html> element (e.g., dark) |
--remove-fixed | false | Remove fixed/sticky elements (cookie banners, chat widgets) |
--full-height | false | Resize viewport to full scroll height |
--title | — | Override page title |
What It Does Automatically
- Inlines all
<link rel="stylesheet">→<style>blocks - Converts
<img>srcand `srcset` → base64 data URIs (skips fonts) - Inlines
<source srcset>URLs as base64 - Removes failed/dead
srcsetentries so the browser falls back to the inlinedsrc - Removes
<script>tags, Vite overlay, Next.js dev indicators - Resolves relative CSS
url()paths before inlining
Framework Notes
| Framework | Notes |
|---|---|
| React + Vite | Works out of the box. --wait 1000. |
| Next.js | --wait 3000 for SSR hydration. URL: http://localhost:3000. <img srcset> from /_next/image is auto-inlined as base64. |
| Vue / Nuxt | Works out of the box. |
| Svelte / SvelteKit | Works out of the box. |
| Storybook | Use story URL: --url http://localhost:6006/?path=/story/... |
| SSR (Webpack) | May need longer --wait. |
Troubleshooting
| Issue | Solution |
|---|---|
| Images missing | Increase --wait |
| Images show as broken after server stops | Verify srcset was inlined — check log for "Inlined N images". If srcset URLs failed, they are auto-removed so src (inlined) is used. |
Next.js /_next/image not inlined | Ensure the dev server is running when snapshot runs — the script fetches optimized images from the running server. |
| Dark mode not applied | --html-class dark |
| Cookie banner in output | --remove-fixed |
| Page requires login | Use the Static Fallback (appendix below) |
Cannot find module 'puppeteer' | npm install -g puppeteer |
***
Strategy B: Browser Subagent Capture
Use when you need to interact with the page (click buttons, fill forms, navigate tabs) before capturing. The browser subagent gives you full control but output may truncate for large pages.
Workflow
1. Start the App locally. 2. Navigate using a browser subagent. 3. Interact as needed (click, scroll, fill forms). 4. Extract DOM: document.documentElement.outerHTML
[!WARNING]
Large pages may truncate. To handle this:
- Remove<style>tags before extraction:document.querySelectorAll('style').forEach(el => el.remove())
- Re-add styles statically (Tailwind CDN link, source CSS)
5. Save to file.
***
Appendix: Static Fallback (MockPage.jsx)
[!NOTE]
This method is a last resort for when the app cannot run locally (broken deps, missing backend, auth walls with no bypass). It requires manually flattening React components into a single JSX file. Prefer Strategy A whenever possible.
When to Use
- App can't run locally at all
- Page requires auth with no mock/bypass
- You need a specific UI state that's impossible to reach by navigation (error screens, empty states)
Quick Reference
npx tsx <SKILL_DIR>/scripts/extract_inline_html.ts \
--index-css src/css/App.css \
--extra-css index.html \
--outdir .stitch \
--page src/MockPage.jsx:Page.html:"Page Title"Key flags: --no-tailwind (non-Tailwind apps), --html-class dark (dark mode), --css-files (extra CSS files).
Auto-detection: Tailwind config is auto-detected. @apply directives automatically use <style type="text/tailwindcss">.
MockPage.jsx Rules
1. Include the full layout — header, sidebar, footer (read App.js first) 2. Flatten all conditionals — pick one state, remove all ternaries and && guards 3. Hardcode all data — replace {variable} with concrete values, unroll .map() loops 4. Preserve logos — use <img> with local paths (post-process will inline them) 5. Remove floating elements — cookie banners, chat widgets, feedback buttons
Post-Processing
Inline local images:
npx tsx <SKILL_DIR>/scripts/post_process.ts \
.stitch/Page.html --base-dir <app-directory>#!/usr/bin/env npx tsx
/**
* extract_inline_html.ts — Convert JSX/React mock files to self-contained HTML.
*
* Uses @babel/parser for proper JSX parsing instead of fragile regex.
* Replaces the old extract_inline_html.py script.
*
* Usage:
* npx tsx extract_inline_html.ts \
* --page src/MockPage.jsx:home.html:"Home Page" \
* --index-css src/index.css \
* --extra-css index.html \
* --outdir .stitch
*
* Flags:
* --page Page spec as src_file:dst_filename:title (repeatable)
* --tailwind-config Path to tailwind.config.js (auto-detected if omitted)
* --no-tailwind Skip Tailwind CDN injection
* --index-css Path to main CSS file
* --css-files Additional CSS files (repeatable)
* --extra-css Path to index.html to extract <style>/<link> from
* --html-class Class(es) for <html> element (e.g., "dark")
* --outdir Output directory (default: .stitch)
* --exclude-pattern Literal string to exclude from body HTML
* --concurrency Max concurrent image fetches (default: 6)
* --timeout HTTP request timeout in ms (default: 15000)
* --json Output machine-readable JSON stats
*/
import * as parser from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';
import fs from 'node:fs';
import path from 'node:path';
import https from 'node:https';
import http from 'node:http';
import type { Node } from '@babel/types';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Opts {
pages: string[];
tailwindConfig: string | null;
noTailwind: boolean;
indexCss: string | null;
cssFiles: string[];
extraCss: string | null;
htmlClass: string | null;
outdir: string;
excludePattern: RegExp | null;
concurrency: number;
timeout: number;
json: boolean;
}
interface CssUrlRef {
url: string;
fullMatch: string;
start: number;
end: number;
}
interface PageStats {
src: string;
dst: string;
sizeBytes: number;
imagesEmbedded: number;
}
interface AllStats {
pages: PageStats[];
totalImages: number;
durationMs: number;
warnings: string[];
}
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
// Escape all regex metacharacters in user input so it is treated as a literal
// string match when used in new RegExp(). Prevents regex injection (ReDoS).
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function parseArgs(): Opts {
const args = process.argv.slice(2);
const opts: Opts = {
pages: [],
tailwindConfig: null,
noTailwind: false,
indexCss: null,
cssFiles: [],
extraCss: null,
htmlClass: null,
outdir: '.stitch',
excludePattern: null,
concurrency: 6,
timeout: 15000,
json: false,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--page': opts.pages.push(args[++i]); break;
case '--tailwind-config': opts.tailwindConfig = args[++i]; break;
case '--no-tailwind': opts.noTailwind = true; break;
case '--index-css': opts.indexCss = args[++i]; break;
case '--css-files': opts.cssFiles.push(args[++i]); break;
case '--extra-css': opts.extraCss = args[++i]; break;
case '--html-class': opts.htmlClass = args[++i]; break;
case '--outdir': opts.outdir = args[++i]; break;
case '--exclude-pattern': {
const rawPattern = args[++i];
// Escape metacharacters so user input is treated as a literal string
opts.excludePattern = new RegExp(escapeRegExp(rawPattern), 'gs');
break;
}
case '--concurrency': opts.concurrency = parseInt(args[++i], 10); break;
case '--timeout': opts.timeout = parseInt(args[++i], 10); break;
case '--json': opts.json = true; break;
case '--help':
console.log(`
Usage: npx tsx extract_inline_html.ts --page <spec> [options]
Options:
--page src_file:dst_filename:title (repeatable)
--tailwind-config Path to tailwind.config.js (auto-detected)
--no-tailwind Skip Tailwind CDN injection
--index-css Path to main CSS file
--css-files Additional CSS files (repeatable)
--extra-css Path to index.html for <style>/<link> extraction
--html-class Class for <html> element (e.g., "dark")
--outdir Output directory (default: .stitch)
--exclude-pattern Literal string to exclude from body
--concurrency Max concurrent image fetches (default: 6)
--timeout HTTP request timeout in ms (default: 15000)
--json Output machine-readable JSON stats
`);
process.exit(0);
default:
console.error(`Unknown argument: ${args[i]}`);
process.exit(1);
}
}
return opts;
}
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
function validateOpts(opts: Opts): void {
const errors: string[] = [];
if (opts.pages.length === 0) {
errors.push('No pages specified. Use --page src:dst:title');
}
for (const spec of opts.pages) {
const parts = spec.split(':');
if (parts.length !== 3) {
errors.push(`Invalid page spec '${spec}'. Must be src:dst:title`);
} else {
const [src] = parts;
if (!fs.existsSync(src)) {
errors.push(`Source file not found: ${src}`);
}
}
}
if (opts.indexCss && !fs.existsSync(opts.indexCss)) {
errors.push(`CSS file not found: ${opts.indexCss}`);
}
for (const f of opts.cssFiles) {
if (!fs.existsSync(f)) {
errors.push(`CSS file not found: ${f}`);
}
}
if (opts.extraCss && !fs.existsSync(opts.extraCss)) {
errors.push(`Extra CSS file not found: ${opts.extraCss}`);
}
if (isNaN(opts.concurrency) || opts.concurrency < 1 || opts.concurrency > 20) {
errors.push('--concurrency must be between 1 and 20');
}
if (isNaN(opts.timeout) || opts.timeout < 1000) {
errors.push('--timeout must be at least 1000ms');
}
if (errors.length > 0) {
console.error('❌ Validation errors:');
errors.forEach((e) => console.error(` • ${e}`));
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Concurrency limiter
// ---------------------------------------------------------------------------
function createLimiter(concurrency: number): <T>(fn: () => Promise<T>) => Promise<T> {
let active = 0;
const queue: Array<() => void> = [];
return function limit<T>(fn: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
const run = async () => {
active++;
try {
resolve(await fn());
} catch (e) {
reject(e);
} finally {
active--;
if (queue.length > 0) queue.shift()!();
}
};
if (active < concurrency) {
run();
} else {
queue.push(run);
}
});
};
}
// ---------------------------------------------------------------------------
// Image embedding (with concurrency, redirect-loop protection, timeouts)
// ---------------------------------------------------------------------------
const imgCache = new Map<string, string>();
const MAX_REDIRECTS = 5;
function isImageUrl(url: string): boolean {
const skip = ['cdn.tailwindcss.com', 'fonts.googleapis.com', '.js', '.css'];
return !skip.some((s) => url.includes(s));
}
/**
* Validate that a URL is safe for outbound requests (SSRF protection).
* Blocks private/internal network addresses and non-HTTP protocols.
* URLs parsed from HTML files could be attacker-controlled, so we must
* ensure they only target public internet hosts.
*/
function isSafeUrl(parsed: URL): boolean {
// Only allow http and https protocols
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Block localhost variants
if (hostname === 'localhost' || hostname === '[::1]') {
return false;
}
// Block private/reserved IPv4 ranges
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
if (
a === 127 || // 127.0.0.0/8 (loopback)
a === 10 || // 10.0.0.0/8 (private)
a === 0 || // 0.0.0.0/8 (unspecified)
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 (private)
(a === 192 && b === 168) || // 192.168.0.0/16 (private)
(a === 169 && b === 254) // 169.254.0.0/16 (link-local)
) {
return false;
}
}
return true;
}
// Intentional outbound requests: this function fetches remote images
// referenced in HTML source files and embeds them as base64 data URIs to
// produce self-contained HTML snapshots. URLs are validated by isSafeUrl()
// to block SSRF against private/internal networks. [CodeQL js/file-access-to-http]
function fetchAndEncode(url: string, timeout: number, redirectCount = 0): Promise<string> {
if (imgCache.has(url)) return Promise.resolve(imgCache.get(url)!);
if (!isImageUrl(url)) {
imgCache.set(url, url);
return Promise.resolve(url);
}
// Redirect-loop protection
if (redirectCount >= MAX_REDIRECTS) {
const fallback =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
console.warn(` WARN: Too many redirects (${MAX_REDIRECTS}) <- ${url.slice(0, 70).replace(/\n|\r/g, '')}...`);
imgCache.set(url, fallback);
return Promise.resolve(fallback);
}
return new Promise((resolve) => {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
const fallback =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
console.warn(` WARN: Invalid URL: ${url.slice(0, 70).replace(/\n|\r/g, '')}...`);
imgCache.set(url, fallback);
resolve(fallback);
return;
}
// SSRF protection: block requests to private/internal networks
if (!isSafeUrl(parsedUrl)) {
const fallback =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
console.warn(` WARN: Blocked request to non-public URL: ${url.slice(0, 70).replace(/\n|\r/g, '')}...`);
imgCache.set(url, fallback);
resolve(fallback);
return;
}
const client = parsedUrl.protocol === 'https:' ? https : http;
const req = client.get(
url,
{
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SnapshotBot/2.0)' },
timeout,
},
(resp) => {
if (
resp.statusCode! >= 300 &&
resp.statusCode! < 400 &&
resp.headers.location
) {
// Resolve relative redirect URLs
let redirectUrl: string;
try {
redirectUrl = new URL(resp.headers.location, url).href;
} catch {
redirectUrl = resp.headers.location;
}
// Consume response body to free the socket
resp.resume();
fetchAndEncode(redirectUrl, timeout, redirectCount + 1).then(resolve);
return;
}
if (resp.statusCode! >= 400) {
const fallback =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
console.warn(
` WARN: HTTP ${resp.statusCode} <- ${url.slice(0, 70).replace(/\n|\r/g, '')}...`,
);
resp.resume();
imgCache.set(url, fallback);
resolve(fallback);
return;
}
const chunks: Buffer[] = [];
resp.on('data', (d: Buffer) => chunks.push(d));
resp.on('end', () => {
const buf = Buffer.concat(chunks);
const ct = resp.headers['content-type'] || 'image/jpeg';
const result = `data:${ct};base64,${buf.toString('base64')}`;
imgCache.set(url, result);
console.log(
` Embedded ${buf.length.toLocaleString()} bytes <- ${url.slice(0, 70).replace(/\n|\r/g, '')}...`,
);
resolve(result);
});
resp.on('error', (e: Error) => {
const fallback =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
console.warn(` WARN: Stream error: ${e.message.replace(/\n|\r/g, '')} <- ${url.slice(0, 70).replace(/\n|\r/g, '')}...`);
imgCache.set(url, fallback);
resolve(fallback);
});
},
);
req.on('error', (e: Error) => {
const fallback =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
console.warn(` WARN: ${e.message.replace(/\n|\r/g, '')} <- ${url.slice(0, 70).replace(/\n|\r/g, '')}...`);
imgCache.set(url, fallback);
resolve(fallback);
});
req.on('timeout', () => {
req.destroy();
const fallback =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
console.warn(` WARN: Timeout after ${timeout}ms <- ${url.slice(0, 70).replace(/\n|\r/g, '')}...`);
imgCache.set(url, fallback);
resolve(fallback);
});
});
}
// ---------------------------------------------------------------------------
// Robust CSS url() parser — character-by-character (no regex)
// ---------------------------------------------------------------------------
function extractCssUrls(text: string): CssUrlRef[] {
const results: CssUrlRef[] = [];
let i = 0;
const len = text.length;
while (i < len) {
// Look for 'url(' — case insensitive
if (
i + 3 < len &&
text[i].toLowerCase() === 'u' &&
text[i + 1].toLowerCase() === 'r' &&
text[i + 2].toLowerCase() === 'l' &&
text[i + 3] === '('
) {
const urlStart = i;
i += 4;
// Skip whitespace
while (i < len && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++;
// Check for quote
let quote: string | null = null;
if (i < len && (text[i] === '"' || text[i] === "'")) {
quote = text[i];
i++;
}
// Read the URL value
let url = '';
if (quote) {
while (i < len && text[i] !== quote) {
if (text[i] === '\\' && i + 1 < len) {
i++;
url += text[i];
} else {
url += text[i];
}
i++;
}
if (i < len) i++; // closing quote
} else {
while (i < len && text[i] !== ')' && text[i] !== ' ' && text[i] !== '\t' && text[i] !== '\n') {
url += text[i];
i++;
}
}
// Skip trailing whitespace before ')'
while (i < len && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++;
if (i < len && text[i] === ')') {
const fullMatch = text.substring(urlStart, i + 1);
results.push({ url: url.trim(), fullMatch, start: urlStart, end: i + 1 });
i++;
} else {
i = urlStart + 1;
}
} else {
i++;
}
}
return results;
}
/**
* Replace CSS url() references using pre-computed positions.
* Replaces from end-to-start to preserve earlier indices.
*/
function replaceCssUrlsInText(
text: string,
replacements: Array<{ start: number; end: number; dataUri: string }>,
): string {
const sorted = [...replacements].sort((a, b) => b.start - a.start);
for (const r of sorted) {
text = text.substring(0, r.start) + "url('" + r.dataUri + "')" + text.substring(r.end);
}
return text;
}
// ---------------------------------------------------------------------------
// Image & CSS url() embedding with concurrency
// ---------------------------------------------------------------------------
async function embedImages(html: string, concurrency: number, timeout: number): Promise<string> {
const limit = createLimiter(concurrency);
// --- Embed <img src="https://..."> ---
const srcMatches = [...html.matchAll(/src="(https?:\/\/[^"]+)"/g)];
const srcImageMatches = srcMatches.filter((m) => isImageUrl(m[1]));
// Prefetch all URLs concurrently
await Promise.all(
srcImageMatches.map((m) => limit(() => fetchAndEncode(m[1], timeout))),
);
// Replace (cache is now warm — synchronous lookups)
for (const m of srcImageMatches) {
const encoded = imgCache.get(m[1]);
if (encoded && encoded !== m[1]) {
html = html.replace(m[0], `src="${encoded}"`);
}
}
// --- Embed CSS url("https://...") using robust parser ---
const cssUrlRefs = extractCssUrls(html);
const httpUrlRefs = cssUrlRefs.filter(
(ref) =>
(ref.url.startsWith('http://') || ref.url.startsWith('https://')) &&
isImageUrl(ref.url),
);
// Prefetch all CSS url() references concurrently
await Promise.all(
httpUrlRefs.map((ref) => limit(() => fetchAndEncode(ref.url, timeout))),
);
// Replace from end-to-start to preserve indices
const replacements: Array<{ start: number; end: number; dataUri: string }> = [];
for (const ref of httpUrlRefs) {
const encoded = imgCache.get(ref.url);
if (encoded && encoded !== ref.url) {
replacements.push({ start: ref.start, end: ref.end, dataUri: encoded });
}
}
if (replacements.length > 0) {
html = replaceCssUrlsInText(html, replacements);
}
// --- Embed <video poster="https://..."> ---
const posterMatches = [...html.matchAll(/poster="(https?:\/\/[^"]+)"/g)];
await Promise.all(
posterMatches.map((m) => limit(() => fetchAndEncode(m[1], timeout))),
);
for (const m of posterMatches) {
const encoded = imgCache.get(m[1]);
if (encoded && encoded !== m[1]) {
html = html.replace(m[0], `poster="${encoded}"`);
}
}
return html;
}
// ---------------------------------------------------------------------------
// JSX → HTML conversion using Babel AST
// ---------------------------------------------------------------------------
// Convert camelCase to kebab-case
function camelToKebab(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
// SVG attribute mapping
const SVG_ATTRS: Record<string, string> = {
strokeLinecap: 'stroke-linecap', strokeLinejoin: 'stroke-linejoin',
strokeWidth: 'stroke-width', strokeDasharray: 'stroke-dasharray',
strokeDashoffset: 'stroke-dashoffset', strokeMiterlimit: 'stroke-miterlimit',
strokeOpacity: 'stroke-opacity', stopColor: 'stop-color',
stopOpacity: 'stop-opacity', fillRule: 'fill-rule', fillOpacity: 'fill-opacity',
clipRule: 'clip-rule', clipPath: 'clip-path', viewBox: 'viewBox',
xlinkHref: 'xlink:href', xmlSpace: 'xml:space', xmlLang: 'xml:lang',
};
// React attribute mapping
const REACT_ATTRS: Record<string, string> = {
className: 'class', htmlFor: 'for', defaultValue: 'value',
defaultChecked: 'checked', tabIndex: 'tabindex', autoFocus: 'autofocus',
autoComplete: 'autocomplete', crossOrigin: 'crossorigin',
};
// HTML void elements (self-closing)
const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr',
]);
function jsxToHtml(jsxSource: string): string | null {
let ast;
try {
ast = parser.parse(jsxSource, {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'optionalChaining', 'nullishCoalescingOperator'],
});
} catch (e: unknown) {
console.error(` Babel parse error: ${(e as Error).message}`);
return null;
}
// Strategy 1: Prefer the JSX return inside the default-exported function,
// since that's the main component in the vast majority of React files.
let returnedJSX: Node | null = null;
traverse(ast, {
ExportDefaultDeclaration(path) {
const decl = path.node.declaration;
// Handle: export default function Component() { return <JSX/> }
if (decl.type === 'FunctionDeclaration') {
path.traverse({
ReturnStatement(retPath) {
const arg = retPath.node.argument;
if (arg && (arg.type === 'JSXElement' || arg.type === 'JSXFragment')) {
returnedJSX = arg;
retPath.stop();
}
},
});
if (returnedJSX) path.stop();
}
// Handle: export default () => <JSX/> or export default () => { return <JSX/> }
if (decl.type === 'ArrowFunctionExpression') {
if (decl.body.type === 'JSXElement' || decl.body.type === 'JSXFragment') {
returnedJSX = decl.body;
path.stop();
} else {
path.traverse({
ReturnStatement(retPath) {
const arg = retPath.node.argument;
if (arg && (arg.type === 'JSXElement' || arg.type === 'JSXFragment')) {
returnedJSX = arg;
retPath.stop();
}
},
});
if (returnedJSX) path.stop();
}
}
},
});
// Strategy 2: Fallback — find the return with the largest JSX tree.
// Covers files that use `export default ComponentName` (identifier) at the
// bottom, or files without any default export at all.
if (!returnedJSX) {
let maxLen = -1;
traverse(ast, {
ReturnStatement(path) {
const arg = path.node.argument;
if (arg && (arg.type === 'JSXElement' || arg.type === 'JSXFragment')) {
const len = (arg.end || 0) - (arg.start || 0);
if (len > maxLen) {
maxLen = len;
returnedJSX = arg;
}
}
},
});
}
if (!returnedJSX) {
console.error(' No JSX return statement found');
return null;
}
return renderNode(returnedJSX);
}
function renderNode(node: any): string {
if (!node) return '';
switch (node.type) {
case 'JSXElement':
return renderElement(node);
case 'JSXFragment':
return node.children.map(renderNode).join('');
case 'JSXText':
return node.value;
case 'JSXExpressionContainer':
return renderExpression(node.expression);
case 'StringLiteral':
return node.value;
default:
return '';
}
}
function renderExpression(expr: any): string {
if (!expr) return '';
switch (expr.type) {
case 'JSXEmptyExpression':
return '';
case 'StringLiteral':
return expr.value;
case 'NumericLiteral':
return String(expr.value);
case 'TemplateLiteral':
// Flatten template literals — just join the quasis
return expr.quasis.map((q: any) => q.value.raw).join('');
default:
console.warn(` WARN: Unhandled JSX expression of type "${expr.type}" inside child node.`);
return '';
}
}
function renderElement(node: any): string {
const tagName = getTagName(node.openingElement);
// Skip <Link> — render children in a <div>
if (tagName === 'Link') {
const attrs = renderAttributes(node.openingElement.attributes, 'div');
const children = node.children.map(renderNode).join('');
return `<div${attrs}>${children}</div>`;
}
// Skip unknown components (capitalized) — just render children
if (tagName[0] === tagName[0].toUpperCase() && tagName[0] !== tagName[0].toLowerCase()) {
return node.children.map(renderNode).join('');
}
const attrs = renderAttributes(node.openingElement.attributes, tagName);
const selfClosing = node.openingElement.selfClosing;
if (selfClosing && VOID_ELEMENTS.has(tagName)) {
return `<${tagName}${attrs}/>`;
}
const children = node.children.map(renderNode).join('');
if (selfClosing && !VOID_ELEMENTS.has(tagName)) {
return `<${tagName}${attrs}></${tagName}>`;
}
return `<${tagName}${attrs}>${children}</${tagName}>`;
}
function getTagName(openingElement: any): string {
if (openingElement.name.type === 'JSXIdentifier') {
return openingElement.name.name;
}
if (openingElement.name.type === 'JSXMemberExpression') {
return `${openingElement.name.object.name}.${openingElement.name.property.name}`;
}
return 'div';
}
function renderAttributes(attrs: any[], tagName: string): string {
if (!attrs || attrs.length === 0) return '';
const parts: string[] = [];
for (const attr of attrs) {
if (attr.type === 'JSXSpreadAttribute') continue; // Skip {...props}
let name: string = attr.name?.name || '';
// Skip event handlers and React-specific props
if (name.startsWith('on') && name[2] === name[2]?.toUpperCase()) continue;
if (['key', 'ref', 'dangerouslySetInnerHTML'].includes(name)) continue;
// Map React attributes
if (REACT_ATTRS[name]) name = REACT_ATTRS[name];
else if (SVG_ATTRS[name]) name = SVG_ATTRS[name];
// Handle value
if (!attr.value) {
// Boolean attribute like `checked`
parts.push(name);
continue;
}
if (attr.value.type === 'StringLiteral') {
// Skip `to` attribute from Link (already handled)
if (name === 'to') continue;
parts.push(`${name}="${attr.value.value}"`);
} else if (attr.value.type === 'JSXExpressionContainer') {
const expr = attr.value.expression;
if (name === 'style' && expr.type === 'ObjectExpression') {
// Convert style={{...}} to style="..."
const styleStr = renderStyleObject(expr);
if (styleStr) parts.push(`style="${styleStr}"`);
} else if (expr.type === 'StringLiteral') {
parts.push(`${name}="${expr.value}"`);
} else if (expr.type === 'NumericLiteral') {
parts.push(`${name}="${expr.value}"`);
} else if (expr.type === 'TemplateLiteral') {
const val = expr.quasis.map((q: any) => q.value.raw).join('');
parts.push(`${name}="${val}"`);
} else {
console.warn(` WARN: Unhandled JSX expression of type "${expr.type}" inside attribute "${name}".`);
}
}
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
function renderStyleObject(objExpr: any): string {
const pairs: string[] = [];
for (const prop of objExpr.properties) {
if (prop.type !== 'ObjectProperty') continue;
const key = prop.key.name || prop.key.value;
if (!key) continue;
const cssKey = camelToKebab(key);
let val: string | undefined;
if (prop.value.type === 'StringLiteral') val = prop.value.value;
else if (prop.value.type === 'NumericLiteral') val = prop.value.value === 0 ? '0' : `${prop.value.value}px`;
else if (prop.value.type === 'TemplateLiteral') val = prop.value.quasis.map((q: any) => q.value.raw).join('');
else continue;
pairs.push(`${cssKey}: ${val}`);
}
return pairs.join('; ');
}
// ---------------------------------------------------------------------------
// Tailwind & CSS helpers
// ---------------------------------------------------------------------------
function autoDetectTailwind(dir = '.'): string | null {
for (const name of ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs', 'tailwind.config.cjs']) {
const p = path.join(dir, name);
if (fs.existsSync(p)) return p;
}
return null;
}
function readCssFile(filePath: string | null): { imports: string[]; css: string; hasApply: boolean } {
if (!filePath || !fs.existsSync(filePath)) return { imports: [], css: '', hasApply: false };
const content = fs.readFileSync(filePath, 'utf-8');
const imports: string[] = [];
const cssLines: string[] = [];
for (const line of content.split('\n')) {
if (line.trim().startsWith('@import')) imports.push(line.trim());
else if (!line.trim().startsWith('@tailwind')) cssLines.push(line);
}
const css = cssLines.join('\n');
return { imports, css, hasApply: /@apply\s+/.test(css) };
}
function extractFromHtml(htmlPath: string | null): { styles: string; links: string[] } {
if (!htmlPath || !fs.existsSync(htmlPath)) return { styles: '', links: [] };
const html = fs.readFileSync(htmlPath, 'utf-8');
const styles: string[] = [];
const links: string[] = [];
// Extract <style> blocks
for (const m of html.matchAll(/<style[^>]*>(.*?)<\/style>/gs)) {
styles.push(m[1].trim());
}
// Extract stylesheet <link> tags with http URLs
for (const m of html.matchAll(/<link[^>]+href="([^"]+)"[^>]*rel="stylesheet"[^>]*\/?>|<link[^>]+rel="stylesheet"[^>]*href="([^"]+)"[^>]*\/?>/g)) {
const href = m[1] || m[2];
if (href?.startsWith('http')) links.push(href);
}
return { styles: styles.join('\n'), links };
}
// ---------------------------------------------------------------------------
// Robust @import URL extraction using parser (not regex)
// ---------------------------------------------------------------------------
function extractImportUrl(importLine: string): string | null {
// Handle: @import url("..."), @import url('...'), @import url(...)
const urlRefs = extractCssUrls(importLine);
if (urlRefs.length > 0) return urlRefs[0].url;
// Handle: @import "..." and @import '...'
const quoteMatch = importLine.match(/@import\s+['"]([^'"]+)['"]/);
if (quoteMatch) return quoteMatch[1];
return null;
}
// ---------------------------------------------------------------------------
// Build head template
// ---------------------------------------------------------------------------
function buildHead(opts: Opts): string {
let useTailwind = !opts.noTailwind;
let tailwindConfig = opts.tailwindConfig;
if (useTailwind && !tailwindConfig) {
tailwindConfig = autoDetectTailwind();
if (tailwindConfig) console.log(`Auto-detected Tailwind config: ${tailwindConfig}`);
else { useTailwind = false; console.log('No Tailwind config found — skipping CDN.'); }
}
// Read CSS
const indexCss = readCssFile(opts.indexCss);
let hasApply = indexCss.hasApply;
let extraCssContent = '';
for (const f of opts.cssFiles) {
if (fs.existsSync(f)) {
const content = fs.readFileSync(f, 'utf-8');
extraCssContent += `/* --- ${path.basename(f)} --- */\n${content}\n`;
if (/@apply\s+/.test(content)) hasApply = true;
console.log(`Included CSS: ${f}`);
}
}
const htmlExtra = extractFromHtml(opts.extraCss);
// Build head
const htmlAttrs = opts.htmlClass ? ` lang="en" class="${opts.htmlClass}"` : ' lang="en"';
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`;
if (useTailwind) {
head += '<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>\n';
}
// @import → <link> (using robust parser)
for (const imp of indexCss.imports) {
const href = extractImportUrl(imp);
if (href) head += `<link href="${href}" rel="stylesheet"/>\n`;
}
// Extra font links from index.html
for (const href of htmlExtra.links) {
head += `<link href="${href}" rel="stylesheet"/>\n`;
}
// Tailwind config
if (useTailwind && tailwindConfig && fs.existsSync(tailwindConfig)) {
let tw = fs.readFileSync(tailwindConfig, 'utf-8');
tw = tw.replace(/export\s+default\s+/, 'tailwind.config = ');
tw = tw.replace(/module\.exports\s*=\s*/, 'tailwind.config = ');
tw = tw.replace(/.*require\(['"]tailwindcss\/colors['"]\).*\n?/g, '');
head += `<script>\n${tw}\n</script>\n`;
}
// Combined CSS
const allCss = `body {\n min-height: 100dvh;\n}\n${indexCss.css}\n${extraCssContent}\n${htmlExtra.styles}`;
const styleType = hasApply && useTailwind ? ' type="text/tailwindcss"' : '';
if (hasApply && useTailwind) {
console.log('Detected @apply — using <style type="text/tailwindcss">');
}
head += `<style${styleType}>\n${allCss}</style>\n</head>\n`;
return head;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const opts = parseArgs();
validateOpts(opts);
const startTime = Date.now();
const head = buildHead(opts);
const stats: AllStats = {
pages: [],
totalImages: 0,
durationMs: 0,
warnings: [],
};
fs.mkdirSync(opts.outdir, { recursive: true });
for (const spec of opts.pages) {
const parts = spec.split(':');
const [src, dstName, title] = parts;
const dst = path.join(opts.outdir, dstName);
console.log(`\n${'='.repeat(60)}`);
console.log(`Converting ${src} -> ${dstName}...`);
console.log(`${'='.repeat(60)}`);
const jsx = fs.readFileSync(src, 'utf-8');
let body = jsxToHtml(jsx);
if (!body) {
const msg = `Failed to parse JSX from ${src}`;
console.error(` ${msg}`);
stats.warnings.push(msg);
continue;
}
// Apply exclude pattern (pre-validated during argument parsing)
if (opts.excludePattern) {
body = body.replace(opts.excludePattern, '');
}
// Extract body class from outer wrapper div
const outerMatch = body.match(/^<div\s+class="([^"]*)"[^>]*>([\s\S]*)<\/div>$/);
let fullHtml: string;
if (outerMatch) {
fullHtml = head.replace('{{title}}', title) +
`<body class="${outerMatch[1]}">\n${outerMatch[2].trim()}\n</body></html>\n`;
} else {
fullHtml = head.replace('{{title}}', title) +
`<body>\n${body}\n</body></html>\n`;
}
// Embed remote images with concurrency
const cacheCountBefore = imgCache.size;
fullHtml = await embedImages(fullHtml, opts.concurrency, opts.timeout);
const imagesEmbedded = imgCache.size - cacheCountBefore;
fs.writeFileSync(dst, fullHtml, 'utf-8');
const fileSize = fs.statSync(dst).size;
console.log(`=> ${dst} (${fileSize.toLocaleString()} bytes)`);
stats.pages.push({
src,
dst,
sizeBytes: fileSize,
imagesEmbedded,
});
}
stats.totalImages = imgCache.size;
stats.durationMs = Date.now() - startTime;
console.log(
`\nDONE: ${imgCache.size} unique images embedded in ${stats.durationMs}ms.`,
);
if (opts.json) {
console.log('\n--- JSON Stats ---');
console.log(JSON.stringify(stats, null, 2));
}
}
main().catch((err: Error) => {
console.error('❌ Error:', err.message);
if (err.stack) console.error(err.stack);
process.exit(1);
});
#!/usr/bin/env npx tsx
/**
* post_process.ts — Inline local images as base64 in extracted HTML files.
*
* Scans HTML files for local image references (src attributes and CSS url()
* values) and replaces them with inline base64 data URIs. Uses a robust
* character-by-character CSS url() parser instead of regex.
*
* Usage:
* npx tsx post_process.ts .stitch/home.html --base-dir my-app
* npx tsx post_process.ts .stitch/page1.html .stitch/page2.html --base-dir .
* npx tsx post_process.ts .stitch/*.html --base-dir . --json
*
* Flags:
* --base-dir Base directory for resolving relative paths
* --json Output machine-readable JSON stats
* --dry-run Report what would be inlined without modifying files
* --max-size Max file size to inline in bytes (default: 5242880 / 5MB)
*/
import fs from 'node:fs';
import path from 'node:path';
// ---------------------------------------------------------------------------
// MIME type mapping
// ---------------------------------------------------------------------------
const MIME_MAP: Record<string, string> = {
'.svg': 'image/svg+xml',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
'.avif': 'image/avif',
'.tiff': 'image/tiff',
'.tif': 'image/tiff',
'.apng': 'image/apng',
'.cur': 'image/x-icon',
};
function getMime(filePath: string): string {
return MIME_MAP[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Opts {
files: string[];
baseDir: string;
json: boolean;
dryRun: boolean;
maxSize: number;
}
interface CssUrlRef {
url: string;
fullMatch: string;
start: number;
end: number;
}
interface InlineStats {
srcInlined: number;
urlInlined: number;
skippedTooLarge: Array<{ path: string; size: number }>;
skippedNotFound: string[];
}
interface FileStats {
file: string;
srcInlined: number;
urlInlined: number;
skippedNotFound: number;
skippedTooLarge: number;
sizeBytes: number;
}
interface AllStats {
files: FileStats[];
totalSrcInlined: number;
totalUrlInlined: number;
totalSkippedNotFound: number;
totalSkippedTooLarge: number;
}
// ---------------------------------------------------------------------------
// Argument parsing & validation
// ---------------------------------------------------------------------------
function parseArgs(): Opts {
const args = process.argv.slice(2);
const opts: Opts = {
files: [],
baseDir: '',
json: false,
dryRun: false,
maxSize: 5 * 1024 * 1024, // 5MB
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--base-dir':
opts.baseDir = args[++i];
break;
case '--json':
opts.json = true;
break;
case '--dry-run':
opts.dryRun = true;
break;
case '--max-size':
opts.maxSize = parseInt(args[++i], 10);
break;
case '--help':
console.log(`
Usage: npx tsx post_process.ts <html_file> [...] [options]
Options:
--base-dir Base directory for resolving relative paths
--json Output machine-readable JSON stats
--dry-run Report what would be inlined without modifying files
--max-size Max file size to inline in bytes (default: 5242880 / 5MB)
`);
process.exit(0);
default:
opts.files.push(args[i]);
}
}
return opts;
}
function validateOpts(opts: Opts): void {
const errors: string[] = [];
if (opts.files.length === 0) {
errors.push('No HTML files specified');
}
if (opts.baseDir && !fs.existsSync(opts.baseDir)) {
errors.push(`Base directory not found: ${opts.baseDir}`);
}
if (isNaN(opts.maxSize) || opts.maxSize < 1) {
errors.push('--max-size must be a positive integer');
}
if (errors.length > 0) {
console.error('❌ Validation errors:');
errors.forEach((e) => console.error(` • ${e}`));
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Robust CSS url() parser — character-by-character (no regex)
// ---------------------------------------------------------------------------
function extractCssUrls(text: string): CssUrlRef[] {
const results: CssUrlRef[] = [];
let i = 0;
const len = text.length;
while (i < len) {
if (
i + 3 < len &&
text[i].toLowerCase() === 'u' &&
text[i + 1].toLowerCase() === 'r' &&
text[i + 2].toLowerCase() === 'l' &&
text[i + 3] === '('
) {
const urlStart = i;
i += 4;
// Skip whitespace
while (i < len && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++;
let quote: string | null = null;
if (i < len && (text[i] === '"' || text[i] === "'")) {
quote = text[i];
i++;
}
let url = '';
if (quote) {
while (i < len && text[i] !== quote) {
if (text[i] === '\\' && i + 1 < len) {
i++;
url += text[i];
} else {
url += text[i];
}
i++;
}
if (i < len) i++;
} else {
while (i < len && text[i] !== ')' && text[i] !== ' ' && text[i] !== '\t' && text[i] !== '\n') {
url += text[i];
i++;
}
}
while (i < len && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++;
if (i < len && text[i] === ')') {
const fullMatch = text.substring(urlStart, i + 1);
results.push({ url: url.trim(), fullMatch, start: urlStart, end: i + 1 });
i++;
} else {
i = urlStart + 1;
}
} else {
i++;
}
}
return results;
}
// ---------------------------------------------------------------------------
// Local path resolution
// ---------------------------------------------------------------------------
function resolveLocalFile(localPath: string, baseDir: string): string | null {
const candidates = [localPath];
if (baseDir) {
candidates.push(path.join(baseDir, localPath.replace(/^\//, '')));
}
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Permission errors, etc. — skip
}
}
return null;
}
/**
* Atomically open, stat, and read a file using a file descriptor.
* Eliminates TOCTOU race conditions by performing all operations on the
* same fd, ensuring the file cannot change between the size check and read.
* Returns null if the file cannot be opened (e.g., deleted between resolve and open).
*/
function readFileAtomic(
filePath: string,
maxSize: number,
): { size: number; mime: string; b64: string } | { size: number; tooLarge: true } | null {
let fd: number;
try {
fd = fs.openSync(filePath, 'r');
} catch {
// File was removed or became inaccessible between resolve and open
return null;
}
try {
const stat = fs.fstatSync(fd);
if (stat.size > maxSize) {
return { size: stat.size, tooLarge: true };
}
const mime = getMime(filePath);
const b64 = fs.readFileSync(fd).toString('base64');
return { size: stat.size, mime, b64 };
} finally {
fs.closeSync(fd);
}
}
/**
* Check if a path is a local (non-remote, non-data) reference.
*/
function isLocalPath(url: string): boolean {
return (
!!url &&
!url.startsWith('http://') &&
!url.startsWith('https://') &&
!url.startsWith('data:') &&
!url.startsWith('//')
);
}
// ---------------------------------------------------------------------------
// Inline images in HTML
// ---------------------------------------------------------------------------
function inlineImages(
html: string,
baseDir: string,
maxSize: number,
dryRun: boolean,
): { html: string; stats: InlineStats } {
const stats: InlineStats = {
srcInlined: 0,
urlInlined: 0,
skippedTooLarge: [],
skippedNotFound: [],
};
// --- Inline src="<local_path>" attributes ---
// Handle src, poster, data attributes
const srcAttrs = ['src', 'poster', 'data'];
for (const attr of srcAttrs) {
const regex = new RegExp(`${attr}="((?!https?:\\/\\/|data:|\\/\\/)[^"]+)"`, 'g');
html = html.replace(regex, (match: string, localPath: string) => {
const resolved = resolveLocalFile(localPath, baseDir);
if (!resolved) {
if (!localPath.endsWith('.js') && !localPath.endsWith('.css')) {
stats.skippedNotFound.push(localPath);
}
return match;
}
const result = readFileAtomic(resolved, maxSize);
if (!result) {
stats.skippedNotFound.push(localPath);
return match;
}
if ('tooLarge' in result) {
stats.skippedTooLarge.push({ path: localPath, size: result.size });
return match;
}
if (dryRun) {
stats.srcInlined++;
return match;
}
stats.srcInlined++;
return `${attr}="data:${result.mime};base64,${result.b64}"`;
});
}
// --- Inline CSS url() with local paths (using robust parser) ---
const urlRefs = extractCssUrls(html);
const localUrlRefs = urlRefs.filter((ref) => isLocalPath(ref.url));
// Process from end to preserve indices
const sorted = [...localUrlRefs].sort((a, b) => b.start - a.start);
for (const ref of sorted) {
const resolved = resolveLocalFile(ref.url, baseDir);
if (!resolved) {
stats.skippedNotFound.push(ref.url);
continue;
}
const result = readFileAtomic(resolved, maxSize);
if (!result) {
stats.skippedNotFound.push(ref.url);
continue;
}
if ('tooLarge' in result) {
stats.skippedTooLarge.push({ path: ref.url, size: result.size });
continue;
}
if (dryRun) {
stats.urlInlined++;
continue;
}
html =
html.substring(0, ref.start) +
`url('data:${result.mime};base64,${result.b64}')` +
html.substring(ref.end);
stats.urlInlined++;
}
// --- Inline SVG <image href="..."> and xlink:href ---
const svgHrefRegex = /(href|xlink:href)="((?!https?:\/\/|data:|\/\/)[^"]+)"/g;
html = html.replace(svgHrefRegex, (match: string, attrName: string, localPath: string) => {
// Skip non-image hrefs (like <a href>)
if (!localPath.match(/\.(svg|png|jpg|jpeg|gif|webp|avif|bmp|ico)$/i)) {
return match;
}
const resolved = resolveLocalFile(localPath, baseDir);
if (!resolved) {
stats.skippedNotFound.push(localPath);
return match;
}
const result = readFileAtomic(resolved, maxSize);
if (!result) {
stats.skippedNotFound.push(localPath);
return match;
}
if ('tooLarge' in result) {
stats.skippedTooLarge.push({ path: localPath, size: result.size });
return match;
}
if (dryRun) {
stats.srcInlined++;
return match;
}
stats.srcInlined++;
return `${attrName}="data:${result.mime};base64,${result.b64}"`;
});
return { html, stats };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void {
const opts = parseArgs();
validateOpts(opts);
const allStats: AllStats = {
files: [],
totalSrcInlined: 0,
totalUrlInlined: 0,
totalSkippedNotFound: 0,
totalSkippedTooLarge: 0,
};
if (opts.dryRun) {
console.log('🔍 DRY RUN — no files will be modified\n');
}
for (const file of opts.files) {
// Open file once with r+ to eliminate TOCTOU race between read and write.
// A single fd is used for both operations, so the file cannot be swapped
// between the read and write phases.
let fd: number;
try {
fd = fs.openSync(file, opts.dryRun ? 'r' : 'r+');
} catch {
console.warn(`⚠️ File not found, skipping: ${file}`);
continue;
}
let processed: string = '';
let stats: InlineStats = { srcInlined: 0, urlInlined: 0, skippedTooLarge: [], skippedNotFound: [] };
try {
const html = fs.readFileSync(fd, 'utf-8');
const result = inlineImages(html, opts.baseDir, opts.maxSize, opts.dryRun);
processed = result.html;
stats = result.stats;
if (!opts.dryRun) {
// Truncate and rewrite using the same fd — no second path-based open
fs.ftruncateSync(fd);
fs.writeSync(fd, processed, 0, 'utf-8');
}
} finally {
fs.closeSync(fd);
}
const totalInlined = stats.srcInlined + stats.urlInlined;
const label = opts.dryRun ? 'would inline' : 'inlined';
console.log(
`${file}: ${label} ${totalInlined} resources ` +
`(${stats.srcInlined} src, ${stats.urlInlined} url()) ` +
`— ${processed.length.toLocaleString()} bytes`,
);
if (stats.skippedTooLarge.length > 0) {
for (const s of stats.skippedTooLarge) {
console.log(
` ⚠️ Skipped (too large: ${(s.size / 1024).toFixed(1)} KB): ${s.path}`,
);
}
}
allStats.files.push({
file,
srcInlined: stats.srcInlined,
urlInlined: stats.urlInlined,
skippedNotFound: stats.skippedNotFound.length,
skippedTooLarge: stats.skippedTooLarge.length,
sizeBytes: processed.length,
});
allStats.totalSrcInlined += stats.srcInlined;
allStats.totalUrlInlined += stats.urlInlined;
allStats.totalSkippedNotFound += stats.skippedNotFound.length;
allStats.totalSkippedTooLarge += stats.skippedTooLarge.length;
}
const totalInlined = allStats.totalSrcInlined + allStats.totalUrlInlined;
console.log(`\n✅ Total: ${totalInlined} resources inlined across ${allStats.files.length} file(s)`);
if (allStats.totalSkippedTooLarge > 0) {
console.log(` ⚠️ ${allStats.totalSkippedTooLarge} skipped (exceeded ${(opts.maxSize / 1024 / 1024).toFixed(1)} MB limit)`);
}
if (opts.json) {
console.log('\n--- JSON Stats ---');
console.log(JSON.stringify(allStats, null, 2));
}
}
main();
#!/usr/bin/env npx tsx
/**
* snapshot.ts — Production-grade Puppeteer-based full-page HTML snapshot
*
* Captures the fully rendered DOM from a running web application and produces
* a self-contained HTML file with all CSS inlined and images converted to
* base64 data URIs. Works with any framework (React, Vue, Svelte, Angular,
* plain HTML, etc.) — no MockPage.jsx needed.
*
* Usage:
* npx tsx snapshot.ts --url http://localhost:5173 --output .stitch/home.html
* npx tsx snapshot.ts --url http://localhost:3000/pricing --output .stitch/pricing.html --html-class dark
* npx tsx snapshot.ts --url http://localhost:5173 --output .stitch/page.html --wait 5000 --viewport 1440x900
*
* Flags:
* --url URL to capture (required)
* --output Output file path (required)
* --wait Extra wait time in ms after network idle (default: 1000)
* --viewport Viewport size as WIDTHxHEIGHT (default: 1280x800)
* --html-class Class(es) to add to <html> element (e.g., "dark")
* --remove-fixed Remove fixed/sticky positioned elements (e.g., cookie banners)
* --full-height Capture full scrollable content by resizing viewport to scrollHeight
* --title Override the page title
* --timeout Global timeout in ms (default: 60000)
* --concurrency Max concurrent resource fetches (default: 6)
* --json Output machine-readable JSON stats to stdout
*/
import puppeteer, { type Browser } from 'puppeteer';
import path from 'node:path';
import fs from 'node:fs';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Opts {
url: string | null;
output: string | null;
wait: number;
viewport: string;
htmlClass: string | null;
removeFixed: boolean;
fullHeight: boolean;
title: string | null;
timeout: number;
concurrency: number;
json: boolean;
inlineFonts: boolean;
removeSelectors: string | null;
click: string | null;
}
interface Stats {
url: string | null;
output: string | null;
sizeBytes: number;
stylesheets: number;
images: number;
cssUrls: number;
svgImages: number;
videoPoster: number;
favicons: number;
scriptsRemoved: number;
warnings: string[];
durationMs: number;
error?: string;
}
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
function parseArgs(): Opts {
const args = process.argv.slice(2);
const opts: Opts = {
url: null,
output: null,
wait: 1000,
viewport: '1280x800',
htmlClass: null,
removeFixed: false,
fullHeight: false,
title: null,
timeout: 60000,
concurrency: 6,
json: false,
inlineFonts: false,
removeSelectors: null,
click: null,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--url':
opts.url = args[++i];
break;
case '--output':
opts.output = args[++i];
break;
case '--wait':
opts.wait = parseInt(args[++i], 10);
break;
case '--viewport':
opts.viewport = args[++i];
break;
case '--html-class':
opts.htmlClass = args[++i];
break;
case '--remove-fixed':
opts.removeFixed = true;
break;
case '--full-height':
opts.fullHeight = true;
break;
case '--title':
opts.title = args[++i];
break;
case '--timeout':
opts.timeout = parseInt(args[++i], 10);
break;
case '--concurrency':
opts.concurrency = parseInt(args[++i], 10);
break;
case '--json':
opts.json = true;
break;
case '--inline-fonts':
opts.inlineFonts = true;
break;
case '--remove-selectors':
opts.removeSelectors = args[++i];
break;
case '--click':
opts.click = args[++i];
break;
case '--help':
console.log(`
Usage: npx tsx snapshot.ts --url <URL> --output <FILE> [options]
Options:
--url URL to capture (required)
--output Output file path (required)
--wait Extra wait time in ms after network idle (default: 1000)
--viewport Viewport size as WIDTHxHEIGHT (default: 1280x800)
--html-class Class(es) to add to <html> element (e.g., "dark")
--remove-fixed Remove fixed/sticky positioned elements (cookie banners, etc.)
--full-height Resize viewport to capture full scrollable content
--title Override the page title
--timeout Global timeout in ms (default: 60000)
--concurrency Max concurrent resource fetches (default: 6)
--json Output machine-readable JSON stats
`);
process.exit(0);
default:
console.error(`Unknown argument: ${args[i]}`);
process.exit(1);
}
}
return opts;
}
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
function validateOpts(opts: Opts): void {
const errors: string[] = [];
if (!opts.url) errors.push('--url is required');
if (!opts.output) errors.push('--output is required');
if (opts.url) {
try {
new URL(opts.url);
} catch {
errors.push(
`Invalid URL: "${opts.url}". Must be a valid URL (e.g., http://localhost:5173)`,
);
}
}
if (opts.viewport) {
const vpMatch = opts.viewport.match(/^(\d+)x(\d+)$/);
if (!vpMatch) {
errors.push(
`Invalid viewport: "${opts.viewport}". Must be WIDTHxHEIGHT (e.g., 1280x800)`,
);
} else {
const w = Number(vpMatch[1]);
const h = Number(vpMatch[2]);
if (w < 1 || h < 1) {
errors.push('Viewport dimensions must be positive integers');
}
if (w > 7680 || h > 4320) {
errors.push('Viewport too large: max 7680x4320');
}
}
}
if (isNaN(opts.wait) || opts.wait < 0) {
errors.push('--wait must be a non-negative integer');
}
if (isNaN(opts.timeout) || opts.timeout < 1000) {
errors.push('--timeout must be at least 1000ms');
}
if (isNaN(opts.concurrency) || opts.concurrency < 1 || opts.concurrency > 20) {
errors.push('--concurrency must be between 1 and 20');
}
if (opts.output) {
const outputDir = path.dirname(path.resolve(opts.output));
try {
fs.mkdirSync(outputDir, { recursive: true });
fs.accessSync(outputDir, fs.constants.W_OK);
} catch (e: unknown) {
errors.push(`Cannot write to output directory: ${(e as Error).message}`);
}
}
if (errors.length > 0) {
console.error('❌ Validation errors:');
errors.forEach((e) => console.error(` • ${e}`));
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Main snapshot logic
// ---------------------------------------------------------------------------
async function snapshot(opts: Opts): Promise<void> {
const [, widthStr, heightStr] = opts.viewport.match(/^(\d+)x(\d+)$/)!;
const width = Number(widthStr);
const height = Number(heightStr);
let browser: Browser | undefined;
let globalTimer: ReturnType<typeof setTimeout> | undefined;
// Stats tracking
const stats: Stats = {
url: opts.url,
output: null,
sizeBytes: 0,
stylesheets: 0,
images: 0,
cssUrls: 0,
svgImages: 0,
videoPoster: 0,
favicons: 0,
scriptsRemoved: 0,
warnings: [],
durationMs: 0,
};
const startTime = Date.now();
try {
// Global timeout safety net — prevents zombie browser processes
globalTimer = setTimeout(() => {
const msg = `Global timeout of ${opts.timeout}ms exceeded — aborting`;
console.error(`⏰ ${msg}`);
stats.warnings.push(msg);
if (browser) browser.close().catch(() => {});
if (opts.json) {
stats.durationMs = Date.now() - startTime;
stats.error = msg;
console.log(JSON.stringify(stats, null, 2));
}
process.exit(2);
}, opts.timeout);
// ----- Launch browser -----
console.log('🚀 Launching browser...');
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
`--window-size=${width},${height}`,
],
});
const page = await browser.newPage();
await page.setViewport({ width, height });
// Forward browser console logs to Node.js
page.on('console', (msg) => {
const type = msg.type().toString();
if (type === 'warning' || type === 'error') {
console.log(` [Browser ${type.toUpperCase()}] ${msg.text()}`);
}
});
// ----- Navigate and wait for network idle -----
console.log(`📄 Navigating to ${opts.url}...`);
try {
await page.goto(opts.url!, {
waitUntil: 'networkidle0',
timeout: Math.min(30000, opts.timeout - 5000),
});
} catch {
const msg = 'networkidle0 timed out, falling back to networkidle2';
console.warn(`⚠️ ${msg}...`);
stats.warnings.push(msg);
await page.goto(opts.url!, {
waitUntil: 'networkidle2',
timeout: Math.min(30000, opts.timeout - 5000),
});
}
// Extra wait for JS-rendered content (animations, lazy loading, etc.)
if (opts.wait > 0) {
console.log(`⏳ Waiting ${opts.wait}ms for rendering to settle...`);
await new Promise((r) => setTimeout(r, opts.wait));
}
// Perform click interaction if specified
if (opts.click) {
console.log(`🖱️ Clicking element "${opts.click}"...`);
try {
let element = await page.$(opts.click);
if (!element) {
// Search in child frames recursively!
for (const frame of page.frames()) {
const childElement = await frame.$(opts.click);
if (childElement) {
element = childElement;
console.log(` Found element inside child frame: ${frame.url()}`);
break;
}
}
}
if (element) {
await element.click();
// wait an extra 2 seconds for animation or modal loading to settle
console.log(` Click succeeded! Waiting 2000ms for click action to settle...`);
await new Promise((r) => setTimeout(r, 2000));
} else {
throw new Error(`Selector "${opts.click}" not found in main document or child frames.`);
}
} catch (clickErr: any) {
console.error(`⚠️ Click action failed:`, clickErr);
stats.warnings.push(`Click action failed: ${clickErr.message || clickErr}`);
}
}
// ----- Pre-processing options -----
// Add class to <html> (e.g., dark mode)
if (opts.htmlClass) {
console.log(`🎨 Adding class "${opts.htmlClass}" to <html>...`);
await page.evaluate((cls: string) => {
document.documentElement.classList.add(...cls.split(/\s+/));
if (cls.includes('dark')) {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (cls.includes('light')) {
document.documentElement.setAttribute('data-theme', 'light');
}
}, opts.htmlClass);
await new Promise((r) => setTimeout(r, 500));
}
// Remove fixed/sticky elements
if (opts.removeFixed) {
console.log('🧹 Removing fixed/sticky positioned elements...');
await page.evaluate(() => {
const all = document.querySelectorAll('*');
for (const el of all) {
const style = getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'sticky') {
const rect = el.getBoundingClientRect();
if (rect.top > 100 || rect.height < 50) {
el.remove();
}
}
}
});
}
// Remove custom selectors
if (opts.removeSelectors) {
console.log(`🧹 Removing custom selectors: "${opts.removeSelectors}"...`);
await page.evaluate((selectors: string) => {
const items = selectors.split(',').map((s) => s.trim()).filter(Boolean);
for (const selector of items) {
try {
document.querySelectorAll(selector).forEach((el) => el.remove());
} catch (e) {
console.warn(`Invalid selector "${selector}":`, e);
}
}
}, opts.removeSelectors);
}
// Override title
if (opts.title) {
await page.evaluate((t: string) => {
document.title = t;
}, opts.title);
}
// ----- Inject shared browser-side helpers (deduplication) -----
// Mock __name to prevent esbuild generated code from failing in browser
await page.evaluate(() => {
(window as any).__name = (fn: any, name: string) => fn;
});
await page.evaluate((concurrency: number) => {
(window as any).__snapshot = {
CONCURRENCY: concurrency,
toDataUri: async (url: string): Promise<string | null> => {
try {
const resp = await fetch(url, {
mode: 'cors',
credentials: 'same-origin',
});
if (!resp.ok) return null;
const blob = await resp.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch {
return null;
}
},
processInBatches: async <T, R>(
items: T[],
batchSize: number,
fn: (item: T) => Promise<R>,
): Promise<(R | null)[]> => {
const results: (R | null)[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.allSettled(batch.map(fn));
results.push(
...batchResults.map((r) =>
r.status === 'fulfilled' ? r.value : null,
),
);
}
return results;
},
/**
* Robust CSS url() parser — character-by-character parsing instead of regex.
* Handles: quoted/unquoted values, escaped characters, whitespace,
* data URIs, and malformed url() tokens.
*
* Returns: Array of { url, fullMatch, start, end }
*/
extractCssUrls: (cssText: string) => {
const results: Array<{ url: string; fullMatch: string; start: number; end: number }> = [];
let i = 0;
const len = cssText.length;
while (i < len) {
// Look for 'url(' — case insensitive
if (
i + 3 < len &&
cssText[i].toLowerCase() === 'u' &&
cssText[i + 1].toLowerCase() === 'r' &&
cssText[i + 2].toLowerCase() === 'l' &&
cssText[i + 3] === '('
) {
const urlStart = i;
i += 4; // skip 'url('
// Skip whitespace
while (
i < len &&
(cssText[i] === ' ' ||
cssText[i] === '\t' ||
cssText[i] === '\n' ||
cssText[i] === '\r')
) {
i++;
}
// Check for quote
let quote: string | null = null;
if (i < len && (cssText[i] === '"' || cssText[i] === "'")) {
quote = cssText[i];
i++;
}
// Read the URL value
let url = '';
if (quote) {
// Quoted: read until matching unescaped quote
while (i < len && cssText[i] !== quote) {
if (cssText[i] === '\\' && i + 1 < len) {
i++; // skip backslash
url += cssText[i]; // include next char literally
} else {
url += cssText[i];
}
i++;
}
if (i < len) i++; // skip closing quote
} else {
// Unquoted: stop at ) or whitespace (per CSS spec)
while (
i < len &&
cssText[i] !== ')' &&
cssText[i] !== ' ' &&
cssText[i] !== '\t' &&
cssText[i] !== '\n' &&
cssText[i] !== '\r'
) {
url += cssText[i];
i++;
}
}
// Skip trailing whitespace before ')'
while (
i < len &&
(cssText[i] === ' ' ||
cssText[i] === '\t' ||
cssText[i] === '\n' ||
cssText[i] === '\r')
) {
i++;
}
if (i < len && cssText[i] === ')') {
const fullMatch = cssText.substring(urlStart, i + 1);
results.push({
url: url.trim(),
fullMatch,
start: urlStart,
end: i + 1,
});
i++;
} else {
// Malformed url() — skip past 'url(' and try again
i = urlStart + 1;
}
} else {
i++;
}
}
return results;
},
/**
* Replace CSS url() references using pre-computed positions.
* Replaces from end-to-start to preserve earlier indices.
*/
replaceCssUrls: (
cssText: string,
replacements: Array<{ start: number; end: number; dataUri: string }>,
): string => {
const sorted = [...replacements].sort((a, b) => b.start - a.start);
for (const r of sorted) {
cssText =
cssText.substring(0, r.start) +
"url('" +
r.dataUri +
"')" +
cssText.substring(r.end);
}
return cssText;
},
};
}, opts.concurrency);
// -----------------------------------------------------------------------
// -1. Inline local iframes (e.g., companion-app test iframe)
// -----------------------------------------------------------------------
const iframesCount = await page.evaluate(() => document.querySelectorAll('iframe').length);
if (iframesCount > 0) {
console.log(`🔍 Found ${iframesCount} iframe(s) in the main page. Extracting content natively...`);
// First, recursively inline all same-origin and srcDoc iframes browser-side
console.log('🔍 Inlining same-origin and srcDoc iframes recursively...');
await page.evaluate(() => {
const inlineSameOriginIframes = (root: Document | HTMLElement) => {
const iframes = Array.from(root.querySelectorAll('iframe'));
for (const iframe of iframes) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc && doc.body) {
// Recursively inline same-origin iframes inside this child frame first
inlineSameOriginIframes(doc);
const bodyHtml = doc.body.innerHTML;
const styles: string[] = [];
doc.querySelectorAll('style').forEach(s => styles.push(s.outerHTML));
doc.querySelectorAll('link[rel="stylesheet"]').forEach(l => styles.push((l as HTMLLinkElement).outerHTML));
styles.forEach(styleHtml => {
const temp = document.createElement('div');
temp.innerHTML = styleHtml;
document.head.appendChild(temp.firstChild!);
});
const wrapper = document.createElement('div');
wrapper.className = 'ac-iframe-inlined-wrapper';
// Apply child body's classes and attributes to same-origin wrapper
for (const attr of Array.from(doc.body.attributes)) {
if (attr.name === 'class') {
wrapper.classList.add(...attr.value.split(/\s+/).filter(Boolean));
} else if (attr.name !== 'style') {
wrapper.setAttribute(attr.name, attr.value);
}
}
wrapper.style.position = 'absolute';
wrapper.style.top = '0';
wrapper.style.left = '0';
wrapper.style.width = '100%';
wrapper.style.height = '100%';
wrapper.style.overflow = 'hidden';
wrapper.innerHTML = bodyHtml;
iframe.parentNode!.replaceChild(wrapper, iframe);
}
} catch (e) {
// Ignore cross-origin iframes; the Puppeteer frame loop will process them
}
}
};
inlineSameOriginIframes(document);
});
const childFrames = page.frames()
.filter(f => f !== page.mainFrame())
.map(f => {
let depth = 0;
let p = f.parentFrame();
while (p) {
depth++;
p = p.parentFrame();
}
return { frame: f, depth };
})
.sort((a, b) => b.depth - a.depth);
for (const { frame } of childFrames) {
try {
const frameUrl = frame.url();
const cleanUrl = frameUrl.split('?')[0].split('#')[0];
console.log(`📦 Extracting frame content from: ${cleanUrl} (depth: ${frame.parentFrame() ? 'nested' : 'root'})`);
// Inject __name mock to prevent esbuild helper ReferenceError in child frame
await frame.evaluate(() => {
(window as any).__name = (fn: any) => fn;
});
// Resolve all relative assets inside the frame to absolute URLs relative to the frame's URL
await frame.evaluate((base) => {
const resolveAttr = (el: Element, attr: string) => {
const val = el.getAttribute(attr);
if (val && !val.startsWith('data:') && !val.startsWith('http:') && !val.startsWith('https:') && !val.startsWith('//')) {
try {
const abs = new URL(val, base).href;
el.setAttribute(attr, abs);
} catch (e) { }
}
};
document.querySelectorAll('img[src]').forEach(img => resolveAttr(img, 'src'));
document.querySelectorAll('img[srcset]').forEach(img => resolveAttr(img, 'srcset'));
document.querySelectorAll('source[srcset]').forEach(src => resolveAttr(src, 'srcset'));
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => resolveAttr(link, 'href'));
// Resolve relative url() references in inline <style> tags
document.querySelectorAll('style').forEach((styleEl) => {
if (styleEl.textContent) {
styleEl.textContent = styleEl.textContent.replace(/url\(['"]?([^'")\s]+)['"]?\)/gi, (match, url) => {
if (
url.startsWith('data:') ||
url.startsWith('http:') ||
url.startsWith('https:') ||
url.startsWith('//')
) {
return match;
}
try {
return `url('${new URL(url, base).href}')`;
} catch {
return match;
}
});
}
});
}, frameUrl);
const frameStyles = await frame.evaluate(() => {
const stylesList: string[] = [];
document.querySelectorAll('style').forEach(s => stylesList.push(s.outerHTML));
document.querySelectorAll('link[rel="stylesheet"]').forEach(l => stylesList.push((l as HTMLLinkElement).outerHTML));
return stylesList;
});
const frameBodyHtml = await frame.evaluate(() => document.body.innerHTML);
const frameBodyAttrs = await frame.evaluate(() => {
const attrs: Record<string, string> = {};
for (const attr of Array.from(document.body.attributes)) {
attrs[attr.name] = attr.value;
}
return attrs;
});
const frameHtmlAttrs = await frame.evaluate(() => {
const attrs: Record<string, string> = {};
for (const attr of Array.from(document.documentElement.attributes)) {
attrs[attr.name] = attr.value;
}
return attrs;
});
const parent = frame.parentFrame();
if (parent) {
await parent.evaluate((url, bodyHtml, styles, bodyAttrs, htmlAttrs) => {
styles.forEach(styleHtml => {
const temp = document.createElement('div');
temp.innerHTML = styleHtml;
document.head.appendChild(temp.firstChild!);
});
// Apply child html attributes to parent documentElement (e.g., data-theme)
for (const [name, val] of Object.entries(htmlAttrs)) {
if (name !== 'style') {
document.documentElement.setAttribute(name, val);
}
}
const iframes = Array.from(document.querySelectorAll('iframe'));
for (const iframe of iframes) {
const cleanIframeSrc = iframe.src.split('?')[0].split('#')[0];
if (cleanIframeSrc && (url.includes(cleanIframeSrc) || cleanIframeSrc.includes(url))) {
const wrapper = document.createElement('div');
wrapper.className = 'ac-iframe-inlined-wrapper';
// Apply child body's classes and attributes to the wrapper
for (const [name, val] of Object.entries(bodyAttrs)) {
if (name === 'class') {
wrapper.classList.add(...val.split(/\s+/).filter(Boolean));
} else if (name !== 'style') {
wrapper.setAttribute(name, val);
}
}
wrapper.style.position = 'absolute';
wrapper.style.top = '0';
wrapper.style.left = '0';
wrapper.style.width = '100%';
wrapper.style.height = '100%';
wrapper.style.overflow = 'hidden';
wrapper.innerHTML = bodyHtml;
iframe.parentNode!.replaceChild(wrapper, iframe);
break;
}
}
}, cleanUrl, frameBodyHtml, frameStyles, frameBodyAttrs, frameHtmlAttrs);
}
} catch (frameErr) {
console.warn('Failed to extract child frame content:', frameErr);
}
}
}
// Resize viewport to full scroll height (executed after iframe contents are natively merged)
if (opts.fullHeight) {
console.log('📐 Scanning DOM for maximum scrollable container height...');
const maxScrollHeight = await page.evaluate(() => {
let maxVal = document.documentElement.scrollHeight;
const all = document.querySelectorAll('*');
for (const el of all) {
const style = getComputedStyle(el);
if (style.overflow === 'auto' || style.overflowY === 'auto' || style.overflow === 'scroll' || style.overflowY === 'scroll') {
if (el.scrollHeight > maxVal) {
maxVal = el.scrollHeight;
}
}
}
return maxVal;
});
// Resize viewport to the true maximum scroll height (plus 120px buffer for safety)
const finalViewportHeight = maxScrollHeight + 120;
console.log(`📐 Resizing viewport to maximum content height: ${finalViewportHeight}px`);
await page.setViewport({ width, height: finalViewportHeight });
// Force layout wrappers and scrollable containers to unlock their heights
await page.evaluate(() => {
document.documentElement.style.setProperty('height', 'auto', 'important');
document.documentElement.style.setProperty('overflow', 'visible', 'important');
document.body.style.setProperty('height', 'auto', 'important');
document.body.style.setProperty('overflow', 'visible', 'important');
const elements = document.querySelectorAll('*');
for (const el of elements) {
const style = getComputedStyle(el);
const hasViewportHeight = style.height.includes('vh') ||
style.height.includes('svh') ||
style.height === '100%' ||
style.height === '100vh' ||
style.height === '100svh' ||
style.maxHeight.includes('vh') ||
style.maxHeight.includes('svh') ||
style.maxHeight === '100%' ||
el.classList.contains('h-svh') ||
el.classList.contains('h-screen') ||
el.classList.contains('ac-iframe-inlined-wrapper') ||
el.classList.contains('ac-iframe');
if (hasViewportHeight) {
(el as HTMLElement).style.setProperty('height', 'auto', 'important');
(el as HTMLElement).style.setProperty('min-height', '0', 'important');
(el as HTMLElement).style.setProperty('max-height', 'none', 'important');
}
if (style.overflow === 'auto' || style.overflowY === 'auto' || style.overflow === 'scroll' || style.overflowY === 'scroll') {
(el as HTMLElement).style.setProperty('height', 'auto', 'important');
(el as HTMLElement).style.setProperty('max-height', 'none', 'important');
(el as HTMLElement).style.setProperty('overflow', 'visible', 'important');
(el as HTMLElement).style.setProperty('position', 'relative', 'important');
}
}
});
await new Promise((r) => setTimeout(r, 1000));
}
// -----------------------------------------------------------------------
// 0. Materialize CSS-in-JS styles into DOM
// -----------------------------------------------------------------------
// CSS-in-JS libraries (Emotion, styled-components, MUI) inject styles
// directly into the CSSOM via insertRule(), leaving <style> tags with
// empty textContent. When we serialize the DOM these rules are lost.
// This step writes the CSSOM rules back into the <style> element's
// textContent so they survive outerHTML serialization.
//
// We only touch <style> tags whose textContent is empty — normal
// stylesheets and text-based <style> blocks are left untouched to
// avoid duplication and preserve cascade order.
console.log('🎨 Materializing CSS-in-JS styles into DOM...');
const cssInJsCount = await page.evaluate(() => {
let count = 0;
const styleElements = document.querySelectorAll('style');
for (const styleEl of styleElements) {
// Skip styles that already have text content (not CSS-in-JS)
if (styleEl.textContent && styleEl.textContent.trim().length > 0) continue;
try {
const sheet = styleEl.sheet;
if (!sheet?.cssRules) continue;
let cssText = '';
for (let j = 0; j < sheet.cssRules.length; j++) {
cssText += sheet.cssRules[j].cssText + '\n';
}
if (cssText.length > 0) {
// Write the rules as text content so they survive DOM serialization
styleEl.textContent = cssText;
count++;
}
} catch (e) {
// Cross-origin or security error, ignore
}
}
return count;
});
console.log(` ✅ Materialized ${cssInJsCount} CSS-in-JS style blocks`);
// -----------------------------------------------------------------------
// 1. Inline all external stylesheets as <style> blocks
// -----------------------------------------------------------------------
console.log('🎨 Inlining external stylesheets...');
stats.stylesheets = await page.evaluate(async () => {
const { toDataUri, extractCssUrls, replaceCssUrls } = (window as any).__snapshot;
let count = 0;
const links = Array.from(
document.querySelectorAll('link[rel="stylesheet"]'),
) as HTMLLinkElement[];
for (const link of links) {
try {
const href = link.href;
if (!href) continue;
const resp = await fetch(href);
if (!resp.ok) continue;
let cssText = await resp.text();
// Resolve relative url() references to absolute URLs using the parser
const baseUrl = new URL(href);
const urlRefs = extractCssUrls(cssText);
const replacements: Array<{ start: number; end: number; dataUri: string }> = [];
for (const ref of urlRefs) {
// Skip absolute URLs, data URIs, and protocol-relative URLs
if (
ref.url.startsWith('data:') ||
ref.url.startsWith('http:') ||
ref.url.startsWith('https:') ||
ref.url.startsWith('//')
) {
continue;
}
try {
const absUrl = new URL(ref.url, baseUrl).href;
replacements.push({
start: ref.start,
end: ref.end,
dataUri: absUrl, // Not a data URI yet — just resolved to absolute
});
} catch {
// Malformed URL — skip
}
}
if (replacements.length > 0) {
cssText = replaceCssUrls(cssText, replacements);
}
const style = document.createElement('style');
style.textContent = cssText;
if (link.media && link.media !== 'all') {
style.setAttribute('media', link.media);
}
link.parentNode!.replaceChild(style, link);
count++;
} catch (e) {
console.warn(`Failed to inline stylesheet: ${link.href}`, e);
}
}
return count;
});
console.log(` ✅ Inlined ${stats.stylesheets} stylesheets`);
// -----------------------------------------------------------------------
// 2. Inline images with concurrency (img, srcset, source, background-image)
// -----------------------------------------------------------------------
console.log('🖼️ Inlining images as base64...');
stats.images = await page.evaluate(async () => {
const { toDataUri, processInBatches, CONCURRENCY } = (window as any).__snapshot;
let count = 0;
// --- <img src="..."> ---
const images = Array.from(document.querySelectorAll('img[src]')) as HTMLImageElement[];
await processInBatches(images, CONCURRENCY, async (img: HTMLImageElement) => {
const src = img.src;
if (!src || src.startsWith('data:')) return;
const dataUri = await toDataUri(src);
if (dataUri) {
img.setAttribute('src', dataUri);
count++;
}
});
// --- <img srcset="..."> ---
const imgsWithSrcset = Array.from(
document.querySelectorAll('img[srcset]'),
) as HTMLImageElement[];
for (const img of imgsWithSrcset) {
const srcset = img.getAttribute('srcset');
if (!srcset) continue;
const parts = srcset.split(',').map((s: string) => s.trim());
const newParts: string[] = [];
await processInBatches(parts, CONCURRENCY, async (part: string) => {
const [url, ...descriptors] = part.split(/\s+/);
if (url.startsWith('data:')) {
newParts.push(part);
return;
}
const dataUri = await toDataUri(url);
if (dataUri) {
newParts.push([dataUri, ...descriptors].join(' '));
count++;
}
// If fetch fails, drop — inlined src is the fallback
});
if (newParts.length > 0) {
img.setAttribute('srcset', newParts.join(', '));
} else {
img.removeAttribute('srcset');
}
}
// --- <source srcset="..."> ---
const sources = Array.from(
document.querySelectorAll('source[srcset]'),
) as HTMLSourceElement[];
for (const source of sources) {
const srcset = source.getAttribute('srcset');
if (!srcset || srcset.startsWith('data:')) continue;
const parts = srcset.split(',').map((s: string) => s.trim());
const newParts: string[] = [];
await processInBatches(parts, CONCURRENCY, async (part: string) => {
const [url, ...descriptors] = part.split(/\s+/);
if (url.startsWith('data:')) {
newParts.push(part);
return;
}
const dataUri = await toDataUri(url);
if (dataUri) {
newParts.push([dataUri, ...descriptors].join(' '));
count++;
}
});
if (newParts.length > 0) {
source.setAttribute('srcset', newParts.join(', '));
} else {
source.remove();
}
}
// --- Inline ALL background-image url() in inline styles (handles multiple) ---
const styledElements = Array.from(
document.querySelectorAll('[style]'),
) as HTMLElement[];
for (const el of styledElements) {
const style = el.getAttribute('style');
if (!style || !style.includes('url(')) continue;
// Use matchAll to handle multiple url() references
const urlPattern =
/url\(['"]?(https?:\/\/[^'"\)\s]+)['"]?\)/g;
const matches = [...style.matchAll(urlPattern)];
if (matches.length === 0) continue;
let newStyle = style;
// Process in reverse order to preserve string positions
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
const dataUri = await toDataUri(m[1]);
if (dataUri) {
newStyle =
newStyle.substring(0, m.index!) +
"url('" +
dataUri +
"')" +
newStyle.substring(m.index! + m[0].length);
count++;
}
}
el.setAttribute('style', newStyle);
}
return count;
});
console.log(` ✅ Inlined ${stats.images} images`);
// -----------------------------------------------------------------------
// 3. Inline CSS url() references in <style> blocks (using parser)
// -----------------------------------------------------------------------
console.log('🔗 Inlining CSS url() references in <style> blocks...');
stats.cssUrls = await page.evaluate(async (inlineFonts) => {
const {
toDataUri,
processInBatches,
extractCssUrls,
replaceCssUrls,
CONCURRENCY,
} = (window as any).__snapshot;
let count = 0;
/** Check if a URL points to a font file (skip — too large, not visual) */
const isFontFile = (url: string): boolean => {
return /\.(woff2?|ttf|eot|otf)(\?|$)/i.test(url);
}
const styles = Array.from(document.querySelectorAll('style')) as HTMLStyleElement[];
for (const styleEl of styles) {
let css = styleEl.textContent!;
const urlRefs = extractCssUrls(css);
// Filter to only http(s) URLs that aren't fonts
const toInline = urlRefs.filter(
(ref: any) =>
(ref.url.startsWith('http://') ||
ref.url.startsWith('https://')) &&
(!isFontFile(ref.url) || inlineFonts),
);
if (toInline.length === 0) continue;
// Fetch all URLs concurrently
const fetched: Array<{ start: number; end: number; dataUri: string }> = [];
await processInBatches(toInline, CONCURRENCY, async (ref: any) => {
const dataUri = await toDataUri(ref.url);
if (dataUri) {
fetched.push({ start: ref.start, end: ref.end, dataUri });
count++;
}
});
if (fetched.length > 0) {
css = replaceCssUrls(css, fetched);
styleEl.textContent = css;
}
}
return count;
}, opts.inlineFonts);
console.log(` ✅ Inlined ${stats.cssUrls} CSS url() references`);
// -----------------------------------------------------------------------
// 4. Inline additional resource types (SVG, video, favicons)
// -----------------------------------------------------------------------
console.log('🔗 Inlining additional resources (SVG, video, favicons)...');
const additionalStats = await page.evaluate(async () => {
const { toDataUri, processInBatches, CONCURRENCY } = (window as any).__snapshot;
const stats = { svgImages: 0, videoPoster: 0, favicons: 0 };
// --- SVG <image href="..."> and <image xlink:href="..."> ---
const svgImages = Array.from(document.querySelectorAll('image')) as SVGImageElement[];
await processInBatches(svgImages, CONCURRENCY, async (img: SVGImageElement) => {
// Check both href and xlink:href
const href =
img.getAttribute('href') ||
img.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (!href || href.startsWith('data:')) return;
const dataUri = await toDataUri(href);
if (dataUri) {
// Set both for compatibility
if (img.hasAttribute('href')) img.setAttribute('href', dataUri);
if (
img.hasAttributeNS('http://www.w3.org/1999/xlink', 'href')
) {
img.setAttributeNS(
'http://www.w3.org/1999/xlink',
'href',
dataUri,
);
}
stats.svgImages++;
}
});
// --- <video poster="..."> ---
const videos = Array.from(
document.querySelectorAll('video[poster]'),
) as HTMLVideoElement[];
await processInBatches(videos, CONCURRENCY, async (video: HTMLVideoElement) => {
const poster = video.getAttribute('poster');
if (!poster || poster.startsWith('data:')) return;
const dataUri = await toDataUri(poster);
if (dataUri) {
video.setAttribute('poster', dataUri);
stats.videoPoster++;
}
});
// --- <link rel="icon"> and <link rel="apple-touch-icon"> favicons ---
const favicons = Array.from(
document.querySelectorAll(
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]',
),
) as HTMLLinkElement[];
await processInBatches(favicons, CONCURRENCY, async (link: HTMLLinkElement) => {
const href = link.href;
if (!href || href.startsWith('data:')) return;
const dataUri = await toDataUri(href);
if (dataUri) {
link.setAttribute('href', dataUri);
stats.favicons++;
}
});
// --- <object data="..."> ---
const objects = Array.from(
document.querySelectorAll('object[data]'),
) as HTMLObjectElement[];
await processInBatches(objects, CONCURRENCY, async (obj: HTMLObjectElement) => {
const data = obj.getAttribute('data');
if (!data || data.startsWith('data:')) return;
// Only inline small objects (SVGs, etc.) — skip large ones
try {
const resp = await fetch(data);
if (!resp.ok) return;
const contentLength = resp.headers.get('content-length');
if (contentLength && Number(contentLength) > 500000) return; // Skip >500KB
const blob = await resp.blob();
const dataUri: string | null = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
if (dataUri) {
obj.setAttribute('data', dataUri);
}
} catch {
// Skip failed objects
}
});
return stats;
});
stats.svgImages = additionalStats.svgImages;
stats.videoPoster = additionalStats.videoPoster;
stats.favicons = additionalStats.favicons;
const additionalTotal =
stats.svgImages + stats.videoPoster + stats.favicons;
console.log(
` ✅ Inlined ${additionalTotal} additional resources ` +
`(${stats.svgImages} SVG, ${stats.videoPoster} video posters, ${stats.favicons} favicons)`,
);
// -----------------------------------------------------------------------
// 5. Remove all <script> tags and dev-tool overlays
// -----------------------------------------------------------------------
console.log('🗑️ Removing <script> tags and dev overlays...');
stats.scriptsRemoved = await page.evaluate(() => {
// Remove all scripts
const scripts = Array.from(document.querySelectorAll('script'));
scripts.forEach((s) => s.remove());
// Remove framework-specific dev overlays
const devSelectors = [
// Vite
'vite-error-overlay',
// Next.js
'[data-nextjs-dialog-overlay]',
'nextjs-portal',
// Webpack/CRA
'#webpack-dev-server-client-overlay',
'#webpack-dev-server-client-overlay-div',
// Parcel
'[data-parcel-error-overlay]',
// Nuxt
'[data-v-inspector]',
];
for (const selector of devSelectors) {
document
.querySelectorAll(selector)
.forEach((el) => el.remove());
}
// Remove noscript tags
document.querySelectorAll('noscript').forEach((el) => el.remove());
return scripts.length;
});
console.log(` ✅ Removed ${stats.scriptsRemoved} scripts`);
// -----------------------------------------------------------------------
// 6. Clean up injected helpers
// -----------------------------------------------------------------------
await page.evaluate(() => {
delete (window as any).__snapshot;
});
// -----------------------------------------------------------------------
// 7. Extract the final HTML and write output
// -----------------------------------------------------------------------
console.log('📦 Extracting final HTML...');
const html = await page.evaluate(
() => '<!DOCTYPE html>\n' + document.documentElement.outerHTML,
);
// Write output
const outputPath = path.resolve(opts.output!);
const outputDir = path.dirname(outputPath);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, html, 'utf-8');
stats.output = outputPath;
stats.sizeBytes = Buffer.byteLength(html);
stats.durationMs = Date.now() - startTime;
const sizeKB = (stats.sizeBytes / 1024).toFixed(1);
console.log(`\n✅ Snapshot saved to ${outputPath} (${sizeKB} KB)`);
console.log(
` ${stats.stylesheets} stylesheets, ${stats.images} images, ${stats.cssUrls} CSS urls inlined`,
);
console.log(
` ${additionalTotal} additional resources (SVG/video/favicon)`,
);
console.log(` ${stats.scriptsRemoved} scripts removed`);
console.log(` Completed in ${stats.durationMs}ms`);
if (stats.warnings.length > 0) {
console.log(`\n⚠️ ${stats.warnings.length} warning(s):`);
stats.warnings.forEach((w) => console.log(` • ${w}`));
}
// JSON output for CI/CD integration
if (opts.json) {
console.log('\n--- JSON Stats ---');
console.log(JSON.stringify(stats, null, 2));
}
} finally {
// Guaranteed browser cleanup — prevents zombie Chrome processes
if (globalTimer) clearTimeout(globalTimer);
if (browser) {
try {
await browser.close();
} catch {
// Browser may already be closed by timeout handler
}
}
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
const opts = parseArgs();
validateOpts(opts);
snapshot(opts).catch((err: Error) => {
console.error('❌ Snapshot failed:', err.message);
if (err.stack) console.error(err.stack);
process.exit(1);
});Related skills
FAQ
Do I need to set up a mock component?
No - Strategy A (Puppeteer) works with any running app. MockPage.jsx is a last resort for apps that cannot run locally.
Will images work after the server stops?
Yes - the script inlines all images as base64 data URIs and removes failed srcset entries so the fallback src is used.
Which strategy should I use?
Strategy A (Puppeteer) is recommended as the default - it requires zero setup and works with any framework. Use Strategy B only if you need to interact with the page first (click, fill forms).
Is Stitch::Extract Static Html safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.