Setting the file. One moment. Post Process · Stitch::extract Static HTML · google-labs-code/stitch-skills · Skills Docs275
function isLocalPath
— line 275
This file
- Number
- 8.2
- Position
- 2 of 3
- Type
- TypeScript
- Size
- 14 KB
- Lines
- 498
scripts/post_process.ts
TypeScript·498 lines·14 KB
* --base-dir Base directory for resolving relative paths
16 * --json Output machine-readable JSON stats
17 * --dry-run Report what would be inlined without modifying files
18 * --max-size Max file size to inline in bytes (default: 5242880 / 5MB)
19 */
20
21import fs from 'node:fs';
22import path from 'node:path';
23
24// ---------------------------------------------------------------------------
25// MIME type mapping
26// ---------------------------------------------------------------------------
27const MIME_MAP: Record<string, string> = {
28 '.svg': 'image/svg+xml',
29 '.jpeg': 'image/jpeg',
30 '.jpg': 'image/jpeg',
31 '.png': 'image/png',
32 '.gif': 'image/gif',
33 '.webp': 'image/webp',
34 '.ico': 'image/x-icon',
35 '.bmp': 'image/bmp',
36 '.avif': 'image/avif',
37 '.tiff': 'image/tiff',
38 '.tif': 'image/tiff',
39 '.apng': 'image/apng',
40 '.cur': 'image/x-icon',
41};
42
43function getMime(filePath: string): string {
44 return MIME_MAP[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
45}
46
47// ---------------------------------------------------------------------------
48// Types
49// ---------------------------------------------------------------------------
50interface Opts {
51 files: string[];
52 baseDir: string;
53 json: boolean;
54 dryRun: boolean;
55 maxSize: number;
56}
57
58interface CssUrlRef {
59 url: string;
60 fullMatch: string;
61 start: number;
62 end: number;
63}
64
65interface InlineStats {
66 srcInlined: number;
67 urlInlined: number;
68 skippedTooLarge: Array<{ path: string; size: number }>;
69 skippedNotFound: string[];
70}
71
72interface FileStats {
73 file: string;
74 srcInlined: number;
75 urlInlined: number;
76 skippedNotFound: number;
77 skippedTooLarge: number;
78 sizeBytes: number;
79}
80
81interface AllStats {
82 files: FileStats[];
83 totalSrcInlined: number;
84 totalUrlInlined: number;
85 totalSkippedNotFound: number;
86 totalSkippedTooLarge: number;
87}
88
89// ---------------------------------------------------------------------------
90// Argument parsing & validation
91// ---------------------------------------------------------------------------
92function parseArgs(): Opts {
93 const args = process.argv.slice(2);
94 const opts: Opts = {
95 files: [],
96 baseDir: '',
97 json: false,
98 dryRun: false,
99 maxSize: 5 * 1024 * 1024, // 5MB
100 };
101
102 for (let i = 0; i < args.length; i++) {
103 switch (args[i]) {
104 case '--base-dir':
105 opts.baseDir = args[++i];
106 break;
107 case '--json':
108 opts.json = true;
109 break;
110 case '--dry-run':
111 opts.dryRun = true;
112 break;
113 case '--max-size':
114 opts.maxSize = parseInt(args[++i], 10);
115 break;
116 case '--help':
117 console.log(`
118Usage: npx tsx post_process.ts <html_file> [...] [options]
119
120Options:
121 --base-dir Base directory for resolving relative paths
122 --json Output machine-readable JSON stats
123 --dry-run Report what would be inlined without modifying files
124 --max-size Max file size to inline in bytes (default: 5242880 / 5MB)
125`);
126 process.exit(0);
127 default:
128 opts.files.push(args[i]);
129 }
130 }
131
132 return opts;
133}
134
135function validateOpts(opts: Opts): void {
136 const errors: string[] = [];
137
138 if (opts.files.length === 0) {
139 errors.push('No HTML files specified');
140 }
141
142 if (opts.baseDir && !fs.existsSync(opts.baseDir)) {
143 errors.push(`Base directory not found: ${opts.baseDir}`);
144 }
145
146 if (isNaN(opts.maxSize) || opts.maxSize < 1) {
147 errors.push('--max-size must be a positive integer');
148 }
149
150 if (errors.length > 0) {
151 console.error('❌ Validation errors:');
152 errors.forEach((e) => console.error(` • ${e}`));
153 process.exit(1);
154 }
155}
156
157// ---------------------------------------------------------------------------
158// Robust CSS url() parser — character-by-character (no regex)
159// ---------------------------------------------------------------------------
160function extractCssUrls(text: string): CssUrlRef[] {
161 const results: CssUrlRef[] = [];
162 let i = 0;
163 const len = text.length;
164
165 while (i < len) {
166 if (
167 i + 3 < len &&
168 text[i].toLowerCase() === 'u' &&
169 text[i + 1].toLowerCase() === 'r' &&
170 text[i + 2].toLowerCase() === 'l' &&
171 text[i + 3] === '('
172 ) {
173 const urlStart = i;
174 i += 4;
175
176 // Skip whitespace
177 while (i < len && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++;
178
179 let quote: string | null = null;
180 if (i < len && (text[i] === '"' || text[i] === "'")) {
181 quote = text[i];
182 i++;
183 }
184
185 let url = '';
186 if (quote) {
187 while (i < len && text[i] !== quote) {
188 if (text[i] === '\\' && i + 1 < len) {
189 i++;
190 url += text[i];
191 } else {
192 url += text[i];
193 }
194 i++;
195 }
196 if (i < len) i++;
197 } else {
198 while (i < len && text[i] !== ')' && text[i] !== ' ' && text[i] !== '\t' && text[i] !== '\n') {
199 url += text[i];
200 i++;
201 }
202 }
203
204 while (i < len && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++;
205
206 if (i < len && text[i] === ')') {
207 const fullMatch = text.substring(urlStart, i + 1);
208 results.push({ url: url.trim(), fullMatch, start: urlStart, end: i + 1 });
209 i++;
210 } else {
211 i = urlStart + 1;
212 }
213 } else {
214 i++;
215 }
216 }
217
218 return results;
219}
220
221// ---------------------------------------------------------------------------
222// Local path resolution
223// ---------------------------------------------------------------------------
224function resolveLocalFile(localPath: string, baseDir: string): string | null {
225 const candidates = [localPath];
226 if (baseDir) {
227 candidates.push(path.join(baseDir, localPath.replace(/^\//, '')));
228 }
229
230 for (const candidate of candidates) {
231 try {
232 if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
233 return candidate;
234 }
235 } catch {
236 // Permission errors, etc. — skip
237 }
238 }
239 return null;
240}
241
242/**
243 * Atomically open, stat, and read a file using a file descriptor.
244 * Eliminates TOCTOU race conditions by performing all operations on the
245 * same fd, ensuring the file cannot change between the size check and read.
246 * Returns null if the file cannot be opened (e.g., deleted between resolve and open).
247 */
248function readFileAtomic(
249 filePath: string,
250 maxSize: number,
251): { size: number; mime: string; b64: string } | { size: number; tooLarge: true } | null {
252 let fd: number;
253 try {
254 fd = fs.openSync(filePath, 'r');
255 } catch {
256 // File was removed or became inaccessible between resolve and open
257 return null;
258 }
259 try {
260 const stat = fs.fstatSync(fd);
261 if (stat.size > maxSize) {
262 return { size: stat.size, tooLarge: true };
263 }
264 const mime = getMime(filePath);
265 const b64 = fs.readFileSync(fd).toString('base64');
266 return { size: stat.size, mime, b64 };
267 } finally {
268 fs.closeSync(fd);
269 }
270}
271
272/**
273 * Check if a path is a local (non-remote, non-data) reference.
274 */
275function isLocalPath(url: string): boolean {
276 return (
277 !!url &&
278 !url.startsWith('http://') &&
279 !url.startsWith('https://') &&
280 !url.startsWith('data:') &&
281 !url.startsWith('//')
282 );
283}
284
285// ---------------------------------------------------------------------------
286// Inline images in HTML
287// ---------------------------------------------------------------------------
288function inlineImages(
289 html: string,
290 baseDir: string,
291 maxSize: number,
292 dryRun: boolean,
293): { html: string; stats: InlineStats } {
294 const stats: InlineStats = {
295 srcInlined: 0,
296 urlInlined: 0,
297 skippedTooLarge: [],
298 skippedNotFound: [],
299 };
300
301 // --- Inline src="<local_path>" attributes ---
302 // Handle src, poster, data attributes
303 const srcAttrs = ['src', 'poster', 'data'];
304 for (const attr of srcAttrs) {
305 const regex = new RegExp(`${attr}="((?!https?:\\/\\/|data:|\\/\\/)[^"]+)"`, 'g');
306 html = html.replace(regex, (match: string, localPath: string) => {
307 const resolved = resolveLocalFile(localPath, baseDir);
308 if (!resolved) {
309 if (!localPath.endsWith('.js') && !localPath.endsWith('.css')) {
310 stats.skippedNotFound.push(localPath);
311 }
312 return match;
313 }
314
315 const result = readFileAtomic(resolved, maxSize);
316 if (!result) {
317 stats.skippedNotFound.push(localPath);
318 return match;
319 }
320 if ('tooLarge' in result) {
321 stats.skippedTooLarge.push({ path: localPath, size: result.size });
322 return match;
323 }
324
325 if (dryRun) {
326 stats.srcInlined++;
327 return match;
328 }
329
330 stats.srcInlined++;
331 return `${attr}="data:${result.mime};base64,${result.b64}"`;
332 });
333 }
334
335 // --- Inline CSS url() with local paths (using robust parser) ---
336 const urlRefs = extractCssUrls(html);
337 const localUrlRefs = urlRefs.filter((ref) => isLocalPath(ref.url));
338
339 // Process from end to preserve indices
340 const sorted = [...localUrlRefs].sort((a, b) => b.start - a.start);
341 for (const ref of sorted) {
342 const resolved = resolveLocalFile(ref.url, baseDir);
343 if (!resolved) {
344 stats.skippedNotFound.push(ref.url);
345 continue;
346 }
347
348 const result = readFileAtomic(resolved, maxSize);
349 if (!result) {
350 stats.skippedNotFound.push(ref.url);
351 continue;
352 }
353 if ('tooLarge' in result) {
354 stats.skippedTooLarge.push({ path: ref.url, size: result.size });
355 continue;
356 }
357
358 if (dryRun) {
359 stats.urlInlined++;
360 continue;
361 }
362
363 html =
364 html.substring(0, ref.start) +
365 `url('data:${result.mime};base64,${result.b64}')` +
366 html.substring(ref.end);
367 stats.urlInlined++;
368 }
369
370 // --- Inline SVG <image href="..."> and xlink:href ---
371 const svgHrefRegex = /(href|xlink:href)="((?!https?:\/\/|data:|\/\/)[^"]+)"/g;
372 html = html.replace(svgHrefRegex, (match: string, attrName: string, localPath: string) => {
373 // Skip non-image hrefs (like <a href>)
374 if (!localPath.match(/\.(svg|png|jpg|jpeg|gif|webp|avif|bmp|ico)$/i)) {
375 return match;
376 }
377
378 const resolved = resolveLocalFile(localPath, baseDir);
379 if (!resolved) {
380 stats.skippedNotFound.push(localPath);
381 return match;
382 }
383
384 const result = readFileAtomic(resolved, maxSize);
385 if (!result) {
386 stats.skippedNotFound.push(localPath);
387 return match;
388 }
389 if ('tooLarge' in result) {
390 stats.skippedTooLarge.push({ path: localPath, size: result.size });
391 return match;
392 }
393
394 if (dryRun) {
395 stats.srcInlined++;
396 return match;
397 }
398
399 stats.srcInlined++;
400 return `${attrName}="data:${result.mime};base64,${result.b64}"`;
401 });
402
403 return { html, stats };
404}
405
406// ---------------------------------------------------------------------------
407// Main
408// ---------------------------------------------------------------------------
409function main(): void {
410 const opts = parseArgs();
411 validateOpts(opts);
412
413 const allStats: AllStats = {
414 files: [],
415 totalSrcInlined: 0,
416 totalUrlInlined: 0,
417 totalSkippedNotFound: 0,
418 totalSkippedTooLarge: 0,
419 };
420
421 if (opts.dryRun) {
422 console.log('🔍 DRY RUN — no files will be modified\n');
423 }
424
425 for (const file of opts.files) {
426 // Open file once with r+ to eliminate TOCTOU race between read and write.
427 // A single fd is used for both operations, so the file cannot be swapped
428 // between the read and write phases.
429 let fd: number;
430 try {
431 fd = fs.openSync(file, opts.dryRun ? 'r' : 'r+');
432 } catch {
433 console.warn(`⚠️ File not found, skipping: ${file}`);
434 continue;
435 }
436
437 let processed: string = '';
438 let stats: InlineStats = { srcInlined: 0, urlInlined: 0, skippedTooLarge: [], skippedNotFound: [] };
439 try {
440 const html = fs.readFileSync(fd, 'utf-8');
441
442 const result = inlineImages(html, opts.baseDir, opts.maxSize, opts.dryRun);
443 processed = result.html;
444 stats = result.stats;
445
446 if (!opts.dryRun) {
447 // Truncate and rewrite using the same fd — no second path-based open
448 fs.ftruncateSync(fd);
449 fs.writeSync(fd, processed, 0, 'utf-8');
450 }
451 } finally {
452 fs.closeSync(fd);
453 }
454
455 const totalInlined = stats.srcInlined + stats.urlInlined;
456 const label = opts.dryRun ? 'would inline' : 'inlined';
457 console.log(
458 `${file}: ${label} ${totalInlined} resources ` +
459 `(${stats.srcInlined} src, ${stats.urlInlined} url()) ` +
460 `— ${processed.length.toLocaleString()} bytes`,
461 );
462
463 if (stats.skippedTooLarge.length > 0) {
464 for (const s of stats.skippedTooLarge) {
465 console.log(
466 ` ⚠️ Skipped (too large: ${(s.size / 1024).toFixed(1)} KB): ${s.path}`,
467 );
468 }
469 }
470
471 allStats.files.push({
472 file,
473 srcInlined: stats.srcInlined,
474 urlInlined: stats.urlInlined,
475 skippedNotFound: stats.skippedNotFound.length,
476 skippedTooLarge: stats.skippedTooLarge.length,
477 sizeBytes: processed.length,
478 });
479 allStats.totalSrcInlined += stats.srcInlined;
480 allStats.totalUrlInlined += stats.urlInlined;
481 allStats.totalSkippedNotFound += stats.skippedNotFound.length;
482 allStats.totalSkippedTooLarge += stats.skippedTooLarge.length;
483 }
484
485 const totalInlined = allStats.totalSrcInlined + allStats.totalUrlInlined;
486 console.log(`\n✅ Total: ${totalInlined} resources inlined across ${allStats.files.length} file(s)`);
487
488 if (allStats.totalSkippedTooLarge > 0) {
489 console.log(` ⚠️ ${allStats.totalSkippedTooLarge} skipped (exceeded ${(opts.maxSize / 1024 / 1024).toFixed(1)} MB limit)`);
490 }
491
492 if (opts.json) {
493 console.log('\n--- JSON Stats ---');
494 console.log(JSON.stringify(allStats, null, 2));
495 }
496}
497
498main();