初始化项目版本
This commit is contained in:
182
.agents/skills/stitch-extract-static-html/SKILL.md
Normal file
182
.agents/skills/stitch-extract-static-html/SKILL.md
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: stitch::extract-static-html
|
||||
description: >-
|
||||
Extract self-contained static HTML from a built web application or React components by inlining CSS and images. Use this skill whenever you need to capture a specific UI state, share a static version of a page, or prepare assets for Stitch upload, even if the user just asks to 'save the HTML' or 'mock the view'.
|
||||
allowed-tools:
|
||||
- "stitch*:*"
|
||||
- "Bash"
|
||||
- "Read"
|
||||
- "Write"
|
||||
- "web_fetch"
|
||||
---
|
||||
|
||||
# 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 `puppeteer` available (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**:
|
||||
```bash
|
||||
npx tsx <SKILL_DIR>/scripts/snapshot.ts \
|
||||
--url http://localhost:5173 \
|
||||
--output .stitch/home.html \
|
||||
--wait 2000
|
||||
```
|
||||
|
||||
3. **Multiple pages** — run once per route:
|
||||
```bash
|
||||
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 dark
|
||||
```
|
||||
|
||||
### Script 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>` `src` **and `srcset`** → base64 data URIs (skips fonts)
|
||||
- Inlines `<source srcset>` URLs as base64
|
||||
- Removes failed/dead `srcset` entries so the browser falls back to the inlined `src`
|
||||
- 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
|
||||
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
npx tsx <SKILL_DIR>/scripts/post_process.ts \
|
||||
.stitch/Page.html --base-dir <app-directory>
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,498 @@
|
||||
#!/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();
|
||||
1329
.agents/skills/stitch-extract-static-html/scripts/snapshot.ts
Normal file
1329
.agents/skills/stitch-extract-static-html/scripts/snapshot.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user