portapack 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.eslintrc.json +9 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/.github/workflows/deploy-pages.yml +56 -0
  4. package/.prettierrc +9 -0
  5. package/.releaserc.js +29 -0
  6. package/CHANGELOG.md +21 -0
  7. package/README.md +288 -0
  8. package/commitlint.config.js +36 -0
  9. package/dist/cli/cli-entry.js +1694 -0
  10. package/dist/cli/cli-entry.js.map +1 -0
  11. package/dist/index.d.ts +275 -0
  12. package/dist/index.js +1405 -0
  13. package/dist/index.js.map +1 -0
  14. package/docs/.vitepress/config.ts +89 -0
  15. package/docs/.vitepress/sidebar-generator.ts +73 -0
  16. package/docs/cli.md +117 -0
  17. package/docs/code-of-conduct.md +65 -0
  18. package/docs/configuration.md +151 -0
  19. package/docs/contributing.md +107 -0
  20. package/docs/demo.md +46 -0
  21. package/docs/deployment.md +132 -0
  22. package/docs/development.md +168 -0
  23. package/docs/getting-started.md +106 -0
  24. package/docs/index.md +40 -0
  25. package/docs/portapack-transparent.png +0 -0
  26. package/docs/portapack.jpg +0 -0
  27. package/docs/troubleshooting.md +107 -0
  28. package/examples/main.ts +118 -0
  29. package/examples/sample-project/index.html +12 -0
  30. package/examples/sample-project/logo.png +1 -0
  31. package/examples/sample-project/script.js +1 -0
  32. package/examples/sample-project/styles.css +1 -0
  33. package/jest.config.ts +124 -0
  34. package/jest.setup.cjs +211 -0
  35. package/nodemon.json +11 -0
  36. package/output.html +1 -0
  37. package/package.json +161 -0
  38. package/site-packed.html +1 -0
  39. package/src/cli/cli-entry.ts +28 -0
  40. package/src/cli/cli.ts +139 -0
  41. package/src/cli/options.ts +151 -0
  42. package/src/core/bundler.ts +201 -0
  43. package/src/core/extractor.ts +618 -0
  44. package/src/core/minifier.ts +233 -0
  45. package/src/core/packer.ts +191 -0
  46. package/src/core/parser.ts +115 -0
  47. package/src/core/web-fetcher.ts +292 -0
  48. package/src/index.ts +262 -0
  49. package/src/types.ts +163 -0
  50. package/src/utils/font.ts +41 -0
  51. package/src/utils/logger.ts +139 -0
  52. package/src/utils/meta.ts +100 -0
  53. package/src/utils/mime.ts +90 -0
  54. package/src/utils/slugify.ts +70 -0
  55. package/test-output.html +0 -0
  56. package/tests/__fixtures__/sample-project/index.html +5 -0
  57. package/tests/unit/cli/cli-entry.test.ts +104 -0
  58. package/tests/unit/cli/cli.test.ts +230 -0
  59. package/tests/unit/cli/options.test.ts +316 -0
  60. package/tests/unit/core/bundler.test.ts +287 -0
  61. package/tests/unit/core/extractor.test.ts +1129 -0
  62. package/tests/unit/core/minifier.test.ts +414 -0
  63. package/tests/unit/core/packer.test.ts +193 -0
  64. package/tests/unit/core/parser.test.ts +540 -0
  65. package/tests/unit/core/web-fetcher.test.ts +374 -0
  66. package/tests/unit/index.test.ts +339 -0
  67. package/tests/unit/utils/font.test.ts +81 -0
  68. package/tests/unit/utils/logger.test.ts +275 -0
  69. package/tests/unit/utils/meta.test.ts +70 -0
  70. package/tests/unit/utils/mime.test.ts +96 -0
  71. package/tests/unit/utils/slugify.test.ts +71 -0
  72. package/tsconfig.build.json +11 -0
  73. package/tsconfig.jest.json +17 -0
  74. package/tsconfig.json +20 -0
  75. package/tsup.config.ts +71 -0
  76. package/typedoc.json +28 -0
package/dist/index.js ADDED
@@ -0,0 +1,1405 @@
1
+ // src/core/parser.ts
2
+ import { readFile } from "fs/promises";
3
+ import * as cheerio from "cheerio";
4
+
5
+ // src/utils/mime.ts
6
+ import path from "path";
7
+ var MIME_MAP = {
8
+ // CSS
9
+ ".css": { mime: "text/css", assetType: "css" },
10
+ // JavaScript
11
+ ".js": { mime: "application/javascript", assetType: "js" },
12
+ ".mjs": { mime: "application/javascript", assetType: "js" },
13
+ // Images
14
+ ".png": { mime: "image/png", assetType: "image" },
15
+ ".jpg": { mime: "image/jpeg", assetType: "image" },
16
+ ".jpeg": { mime: "image/jpeg", assetType: "image" },
17
+ ".gif": { mime: "image/gif", assetType: "image" },
18
+ ".svg": { mime: "image/svg+xml", assetType: "image" },
19
+ ".webp": { mime: "image/webp", assetType: "image" },
20
+ ".ico": { mime: "image/x-icon", assetType: "image" },
21
+ ".avif": { mime: "image/avif", assetType: "image" },
22
+ // Fonts
23
+ ".woff": { mime: "font/woff", assetType: "font" },
24
+ ".woff2": { mime: "font/woff2", assetType: "font" },
25
+ ".ttf": { mime: "font/ttf", assetType: "font" },
26
+ ".otf": { mime: "font/otf", assetType: "font" },
27
+ ".eot": { mime: "application/vnd.ms-fontobject", assetType: "font" },
28
+ // Audio/Video (add more as needed)
29
+ ".mp3": { mime: "audio/mpeg", assetType: "other" },
30
+ ".ogg": { mime: "audio/ogg", assetType: "other" },
31
+ ".wav": { mime: "audio/wav", assetType: "other" },
32
+ ".mp4": { mime: "video/mp4", assetType: "other" },
33
+ ".webm": { mime: "video/webm", assetType: "other" },
34
+ // Other common web types
35
+ ".json": { mime: "application/json", assetType: "other" },
36
+ ".webmanifest": { mime: "application/manifest+json", assetType: "other" },
37
+ ".xml": { mime: "application/xml", assetType: "other" },
38
+ ".html": { mime: "text/html", assetType: "other" },
39
+ // Usually not needed as asset, but for completeness
40
+ ".txt": { mime: "text/plain", assetType: "other" }
41
+ };
42
+ var DEFAULT_MIME_TYPE = {
43
+ mime: "application/octet-stream",
44
+ assetType: "other"
45
+ // Explicit cast needed
46
+ };
47
+ function guessMimeType(urlOrPath) {
48
+ if (!urlOrPath) {
49
+ return DEFAULT_MIME_TYPE;
50
+ }
51
+ let ext = "";
52
+ try {
53
+ const parsedUrl = new URL(urlOrPath);
54
+ ext = path.extname(parsedUrl.pathname).toLowerCase();
55
+ } catch {
56
+ ext = path.extname(urlOrPath).toLowerCase();
57
+ }
58
+ return MIME_MAP[ext] || DEFAULT_MIME_TYPE;
59
+ }
60
+
61
+ // src/core/parser.ts
62
+ async function parseHTML(entryFilePath, logger) {
63
+ logger?.debug(`Parsing HTML file: ${entryFilePath}`);
64
+ let htmlContent;
65
+ try {
66
+ htmlContent = await readFile(entryFilePath, "utf-8");
67
+ logger?.debug(`Successfully read HTML file (${Buffer.byteLength(htmlContent)} bytes).`);
68
+ } catch (err) {
69
+ logger?.error(`Failed to read HTML file "${entryFilePath}": ${err.message}`);
70
+ throw new Error(`Could not read input HTML file: ${entryFilePath}`, { cause: err });
71
+ }
72
+ const $ = cheerio.load(htmlContent);
73
+ const assets = [];
74
+ const addedUrls = /* @__PURE__ */ new Set();
75
+ const addAsset = (url, forcedType) => {
76
+ if (!url || url.trim() === "" || url.startsWith("data:")) {
77
+ return;
78
+ }
79
+ if (!addedUrls.has(url)) {
80
+ addedUrls.add(url);
81
+ const mimeInfo = guessMimeType(url);
82
+ const type = forcedType ?? mimeInfo.assetType;
83
+ assets.push({ type, url });
84
+ logger?.debug(`Discovered asset: Type='${type}', URL='${url}'`);
85
+ } else {
86
+ logger?.debug(`Skipping duplicate asset URL: ${url}`);
87
+ }
88
+ };
89
+ logger?.debug("Extracting assets from HTML tags...");
90
+ $('link[rel="stylesheet"][href]').each((_, el) => {
91
+ addAsset($(el).attr("href"), "css");
92
+ });
93
+ $("script[src]").each((_, el) => {
94
+ addAsset($(el).attr("src"), "js");
95
+ });
96
+ $("img[src]").each((_, el) => addAsset($(el).attr("src"), "image"));
97
+ $('input[type="image"][src]').each((_, el) => addAsset($(el).attr("src"), "image"));
98
+ $("img[srcset], picture source[srcset]").each((_, el) => {
99
+ const srcset = $(el).attr("srcset");
100
+ srcset?.split(",").forEach((entry) => {
101
+ const [url] = entry.trim().split(/\s+/);
102
+ addAsset(url, "image");
103
+ });
104
+ });
105
+ $("video[src]").each((_, el) => addAsset($(el).attr("src"), "video"));
106
+ $("video[poster]").each((_, el) => addAsset($(el).attr("poster"), "image"));
107
+ $("audio[src]").each((_, el) => addAsset($(el).attr("src"), "audio"));
108
+ $("video > source[src]").each((_, el) => addAsset($(el).attr("src"), "video"));
109
+ $("audio > source[src]").each((_, el) => addAsset($(el).attr("src"), "audio"));
110
+ $("link[href]").filter((_, el) => {
111
+ const rel = $(el).attr("rel")?.toLowerCase() ?? "";
112
+ return ["icon", "shortcut icon", "apple-touch-icon", "manifest"].includes(rel);
113
+ }).each((_, el) => {
114
+ const rel = $(el).attr("rel")?.toLowerCase() ?? "";
115
+ const isIcon = ["icon", "shortcut icon", "apple-touch-icon"].includes(rel);
116
+ addAsset($(el).attr("href"), isIcon ? "image" : void 0);
117
+ });
118
+ $('link[rel="preload"][as="font"][href]').each((_, el) => {
119
+ addAsset($(el).attr("href"), "font");
120
+ });
121
+ logger?.info(`HTML parsing complete. Discovered ${assets.length} unique asset links.`);
122
+ return { htmlContent, assets };
123
+ }
124
+
125
+ // src/core/extractor.ts
126
+ import { readFile as readFile2 } from "fs/promises";
127
+ import * as fs from "fs";
128
+ import path2 from "path";
129
+ import { fileURLToPath, URL as URL2 } from "url";
130
+ import * as axios from "axios";
131
+ var TEXT_ASSET_TYPES = /* @__PURE__ */ new Set(["css", "js"]);
132
+ var BINARY_ASSET_TYPES = /* @__PURE__ */ new Set(["image", "font", "video", "audio"]);
133
+ var MAX_ASSET_EXTRACTION_ITERATIONS = 1e3;
134
+ function isUtf8DecodingLossy(originalBuffer, decodedString) {
135
+ try {
136
+ const reEncodedBuffer = Buffer.from(decodedString, "utf-8");
137
+ return !originalBuffer.equals(reEncodedBuffer);
138
+ } catch (e) {
139
+ return true;
140
+ }
141
+ }
142
+ function determineBaseUrl(inputPathOrUrl, logger) {
143
+ logger?.debug(`Determining base URL for input: ${inputPathOrUrl}`);
144
+ if (!inputPathOrUrl) {
145
+ logger?.warn("Cannot determine base URL: inputPathOrUrl is empty or invalid.");
146
+ return void 0;
147
+ }
148
+ try {
149
+ if (/^https?:\/\//i.test(inputPathOrUrl)) {
150
+ const url = new URL2(inputPathOrUrl);
151
+ url.pathname = url.pathname.substring(0, url.pathname.lastIndexOf("/") + 1);
152
+ url.search = "";
153
+ url.hash = "";
154
+ const baseUrl = url.href;
155
+ logger?.debug(`Determined remote base URL: ${baseUrl}`);
156
+ return baseUrl;
157
+ } else if (inputPathOrUrl.includes("://") && !inputPathOrUrl.startsWith("file:")) {
158
+ logger?.warn(`Input "${inputPathOrUrl}" looks like a URL but uses an unsupported protocol. Cannot determine base URL.`);
159
+ return void 0;
160
+ } else {
161
+ let absolutePath;
162
+ if (inputPathOrUrl.startsWith("file:")) {
163
+ try {
164
+ absolutePath = fileURLToPath(inputPathOrUrl);
165
+ } catch (e) {
166
+ logger?.error(`\u{1F480} Failed to convert file URL "${inputPathOrUrl}" to path: ${e.message}`);
167
+ return void 0;
168
+ }
169
+ } else {
170
+ absolutePath = path2.resolve(inputPathOrUrl);
171
+ }
172
+ let isDirectory = false;
173
+ try {
174
+ isDirectory = fs.statSync(absolutePath).isDirectory();
175
+ } catch (statError) {
176
+ if (statError instanceof Error && statError.code === "ENOENT") {
177
+ logger?.debug(`Path "${absolutePath}" not found. Assuming input represents a file, using its parent directory as base.`);
178
+ } else {
179
+ logger?.warn(`Could not stat local path "${absolutePath}" during base URL determination: ${statError instanceof Error ? statError.message : String(statError)}. Assuming input represents a file.`);
180
+ }
181
+ isDirectory = false;
182
+ }
183
+ const dirPath = isDirectory ? absolutePath : path2.dirname(absolutePath);
184
+ let normalizedPathForURL = dirPath.replace(/\\/g, "/");
185
+ if (/^[A-Z]:\//i.test(normalizedPathForURL) && !normalizedPathForURL.startsWith("/")) {
186
+ normalizedPathForURL = "/" + normalizedPathForURL;
187
+ }
188
+ const fileUrl = new URL2("file://" + normalizedPathForURL);
189
+ let fileUrlString = fileUrl.href;
190
+ if (!fileUrlString.endsWith("/")) {
191
+ fileUrlString += "/";
192
+ }
193
+ logger?.debug(`Determined local base URL: ${fileUrlString} (from: ${inputPathOrUrl}, resolved dir: ${dirPath}, isDir: ${isDirectory})`);
194
+ return fileUrlString;
195
+ }
196
+ } catch (error) {
197
+ const message = error instanceof Error ? error.message : String(error);
198
+ logger?.error(`\u{1F480} Failed to determine base URL for "${inputPathOrUrl}": ${message}${error instanceof Error ? ` - Stack: ${error.stack}` : ""}`);
199
+ return void 0;
200
+ }
201
+ }
202
+ function resolveAssetUrl(assetUrl, baseContextUrl, logger) {
203
+ const trimmedUrl = assetUrl?.trim();
204
+ if (!trimmedUrl || trimmedUrl.startsWith("data:") || trimmedUrl.startsWith("#")) {
205
+ return null;
206
+ }
207
+ let resolvableUrl = trimmedUrl;
208
+ if (resolvableUrl.startsWith("//") && baseContextUrl) {
209
+ try {
210
+ const base = new URL2(baseContextUrl);
211
+ resolvableUrl = base.protocol + resolvableUrl;
212
+ } catch (e) {
213
+ logger?.warn(`Could not extract protocol from base "${baseContextUrl}" for protocol-relative URL "${trimmedUrl}". Skipping.`);
214
+ return null;
215
+ }
216
+ }
217
+ try {
218
+ const resolved = new URL2(resolvableUrl, baseContextUrl);
219
+ return resolved;
220
+ } catch (error) {
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ if (!/^[a-z]+:/i.test(resolvableUrl) && !resolvableUrl.startsWith("/") && !baseContextUrl) {
223
+ logger?.warn(`Cannot resolve relative URL "${resolvableUrl}" - Base context URL was not provided or determined.`);
224
+ } else {
225
+ logger?.warn(`\u26A0\uFE0F Failed to parse/resolve URL "${resolvableUrl}" ${baseContextUrl ? 'against base "' + baseContextUrl + '"' : "(no base provided)"}: ${message}`);
226
+ }
227
+ return null;
228
+ }
229
+ }
230
+ function resolveCssRelativeUrl(relativeUrl, cssBaseContextUrl, logger) {
231
+ if (!relativeUrl || relativeUrl.startsWith("data:")) {
232
+ return null;
233
+ }
234
+ try {
235
+ if (cssBaseContextUrl.startsWith("file:")) {
236
+ const basePath = fileURLToPath(cssBaseContextUrl);
237
+ let cssDir;
238
+ try {
239
+ const stat = fs.statSync(basePath);
240
+ if (stat.isDirectory()) {
241
+ cssDir = basePath;
242
+ } else {
243
+ cssDir = path2.dirname(basePath);
244
+ }
245
+ } catch {
246
+ cssDir = path2.dirname(basePath);
247
+ }
248
+ let resolvedPath = path2.resolve(cssDir, relativeUrl);
249
+ resolvedPath = resolvedPath.replace(/\\/g, "/");
250
+ if (/^[A-Z]:/i.test(resolvedPath) && !resolvedPath.startsWith("/")) {
251
+ resolvedPath = "/" + resolvedPath;
252
+ }
253
+ return `file://${resolvedPath}`;
254
+ } else {
255
+ return new URL2(relativeUrl, cssBaseContextUrl).href;
256
+ }
257
+ } catch (error) {
258
+ logger?.warn(
259
+ `Failed to resolve CSS URL: "${relativeUrl}" against "${cssBaseContextUrl}": ${String(error)}`
260
+ );
261
+ return null;
262
+ }
263
+ }
264
+ async function fetchAsset(resolvedUrl, logger, timeout = 1e4) {
265
+ logger?.debug(`Attempting to fetch asset: ${resolvedUrl.href}`);
266
+ const protocol = resolvedUrl.protocol;
267
+ try {
268
+ if (protocol === "http:" || protocol === "https:") {
269
+ const response = await axios.default.get(resolvedUrl.href, {
270
+ responseType: "arraybuffer",
271
+ timeout
272
+ });
273
+ logger?.debug(`Workspaceed remote asset ${resolvedUrl.href} (Status: ${response.status}, Type: ${response.headers["content-type"] || "N/A"}, Size: ${response.data.byteLength} bytes)`);
274
+ return Buffer.from(response.data);
275
+ } else if (protocol === "file:") {
276
+ let filePath;
277
+ try {
278
+ filePath = fileURLToPath(resolvedUrl);
279
+ } catch (e) {
280
+ logger?.error(`Could not convert file URL to path: ${resolvedUrl.href}. Error: ${e.message}`);
281
+ return null;
282
+ }
283
+ const data = await readFile2(filePath);
284
+ logger?.debug(`Read local file ${filePath} (${data.byteLength} bytes)`);
285
+ return data;
286
+ } else {
287
+ logger?.warn(`Unsupported protocol "${protocol}" in URL: ${resolvedUrl.href}`);
288
+ return null;
289
+ }
290
+ } catch (error) {
291
+ if ((protocol === "http:" || protocol === "https:") && axios.default.isAxiosError(error)) {
292
+ const status = error.response?.status ?? "N/A";
293
+ const statusText = error.response?.statusText ?? "Error";
294
+ const code = error.code ?? "N/A";
295
+ const message = error.message;
296
+ const logMessage = `\u26A0\uFE0F Failed to fetch remote asset ${resolvedUrl.href}: Status ${status} - ${statusText}. Code: ${code}, Message: ${message}`;
297
+ logger?.warn(logMessage);
298
+ } else if (protocol === "file:") {
299
+ let failedPath = resolvedUrl.href;
300
+ try {
301
+ failedPath = fileURLToPath(resolvedUrl);
302
+ } catch {
303
+ }
304
+ if (error instanceof Error && error.code === "ENOENT") {
305
+ logger?.warn(`\u26A0\uFE0F File not found (ENOENT) for asset: ${failedPath}.`);
306
+ } else if (error instanceof Error && error.code === "EACCES") {
307
+ logger?.warn(`\u26A0\uFE0F Permission denied (EACCES) reading asset: ${failedPath}.`);
308
+ } else if (error instanceof Error) {
309
+ logger?.warn(`\u26A0\uFE0F Failed to read local asset ${failedPath}: ${error.message}`);
310
+ } else {
311
+ logger?.warn(`\u26A0\uFE0F An unknown error occurred while reading local asset ${failedPath}: ${String(error)}`);
312
+ }
313
+ } else if (error instanceof Error) {
314
+ logger?.warn(`\u26A0\uFE0F An unexpected error occurred processing asset ${resolvedUrl.href}: ${error.message}`);
315
+ } else {
316
+ logger?.warn(`\u26A0\uFE0F An unknown and unexpected error occurred processing asset ${resolvedUrl.href}: ${String(error)}`);
317
+ }
318
+ return null;
319
+ }
320
+ }
321
+ function extractUrlsFromCSS(cssContent, cssBaseContextUrl, logger) {
322
+ const newlyDiscovered = [];
323
+ const processedInThisParse = /* @__PURE__ */ new Set();
324
+ const urlRegex = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
325
+ const importRegex = /@import\s+(?:url\(\s*(['"]?)(.*?)\1\s*\)|(['"])(.*?)\3)\s*;/gi;
326
+ const processFoundUrl = (rawUrl, ruleType) => {
327
+ if (!rawUrl || rawUrl.trim() === "" || rawUrl.startsWith("data:")) return;
328
+ const resolvedUrl = resolveCssRelativeUrl(rawUrl, cssBaseContextUrl, logger);
329
+ if (resolvedUrl && !processedInThisParse.has(resolvedUrl)) {
330
+ processedInThisParse.add(resolvedUrl);
331
+ const { assetType } = guessMimeType(resolvedUrl);
332
+ newlyDiscovered.push({
333
+ type: assetType,
334
+ url: resolvedUrl,
335
+ // The resolved URL string
336
+ content: void 0
337
+ });
338
+ logger?.debug(`Discovered nested ${assetType} asset (${ruleType}) in CSS ${cssBaseContextUrl}: ${resolvedUrl}`);
339
+ }
340
+ };
341
+ urlRegex.lastIndex = 0;
342
+ importRegex.lastIndex = 0;
343
+ let match;
344
+ while ((match = urlRegex.exec(cssContent)) !== null) {
345
+ processFoundUrl(match[2], "url()");
346
+ }
347
+ importRegex.lastIndex = 0;
348
+ while ((match = importRegex.exec(cssContent)) !== null) {
349
+ processFoundUrl(match[2] || match[4], "@import");
350
+ }
351
+ return newlyDiscovered;
352
+ }
353
+ async function extractAssets(parsed, embedAssets = true, inputPathOrUrl, logger) {
354
+ logger?.info(`\u{1F680} Starting asset extraction! Embed: ${embedAssets}. Input: ${inputPathOrUrl || "(HTML content only)"}`);
355
+ const initialAssets = parsed.assets || [];
356
+ const finalAssetsMap = /* @__PURE__ */ new Map();
357
+ let assetsToProcess = [];
358
+ const htmlBaseContextUrl = determineBaseUrl(inputPathOrUrl || "", logger);
359
+ if (!htmlBaseContextUrl && initialAssets.some((a) => !/^[a-z]+:/i.test(a.url) && !a.url.startsWith("data:") && !a.url.startsWith("#") && !a.url.startsWith("/"))) {
360
+ logger?.warn("\u{1F6A8} No valid base path/URL determined for the HTML source! Resolution of relative asset paths from HTML may fail.");
361
+ } else if (htmlBaseContextUrl) {
362
+ logger?.debug(`Using HTML base context URL: ${htmlBaseContextUrl}`);
363
+ }
364
+ const processedOrQueuedUrls = /* @__PURE__ */ new Set();
365
+ logger?.debug(`Queueing ${initialAssets.length} initial assets parsed from HTML...`);
366
+ for (const asset of initialAssets) {
367
+ const resolvedUrlObj = resolveAssetUrl(asset.url, htmlBaseContextUrl, logger);
368
+ const urlToQueue = resolvedUrlObj ? resolvedUrlObj.href : asset.url;
369
+ if (!urlToQueue.startsWith("data:") && !processedOrQueuedUrls.has(urlToQueue)) {
370
+ processedOrQueuedUrls.add(urlToQueue);
371
+ const { assetType: guessedType } = guessMimeType(urlToQueue);
372
+ const initialType = asset.type ?? guessedType;
373
+ assetsToProcess.push({
374
+ url: urlToQueue,
375
+ type: initialType,
376
+ content: void 0
377
+ });
378
+ logger?.debug(` -> Queued initial asset: ${urlToQueue} (Original raw: ${asset.url})`);
379
+ } else if (urlToQueue.startsWith("data:")) {
380
+ logger?.debug(` -> Skipping data URI: ${urlToQueue.substring(0, 50)}...`);
381
+ } else {
382
+ logger?.debug(` -> Skipping already queued initial asset: ${urlToQueue}`);
383
+ }
384
+ }
385
+ let iterationCount = 0;
386
+ while (assetsToProcess.length > 0) {
387
+ iterationCount++;
388
+ if (iterationCount > MAX_ASSET_EXTRACTION_ITERATIONS) {
389
+ logger?.error(`\u{1F6D1} Asset extraction loop limit hit (${MAX_ASSET_EXTRACTION_ITERATIONS})! Aborting.`);
390
+ const remainingUrls = assetsToProcess.map((a) => a.url).slice(0, 10).join(", ");
391
+ logger?.error(`Remaining queue sample (${assetsToProcess.length} items): ${remainingUrls}...`);
392
+ assetsToProcess.forEach((asset) => {
393
+ if (!finalAssetsMap.has(asset.url)) {
394
+ finalAssetsMap.set(asset.url, { ...asset, content: void 0 });
395
+ }
396
+ });
397
+ assetsToProcess = [];
398
+ break;
399
+ }
400
+ const currentBatch = [...assetsToProcess];
401
+ assetsToProcess = [];
402
+ logger?.debug(`--- Processing batch ${iterationCount}: ${currentBatch.length} asset(s) ---`);
403
+ for (const asset of currentBatch) {
404
+ if (finalAssetsMap.has(asset.url)) {
405
+ logger?.debug(`Skipping asset already in final map: ${asset.url}`);
406
+ continue;
407
+ }
408
+ let assetContentBuffer = null;
409
+ let finalContent = void 0;
410
+ let cssContentForParsing = void 0;
411
+ const needsFetching = embedAssets || asset.type === "css";
412
+ let assetUrlObj = null;
413
+ if (needsFetching) {
414
+ try {
415
+ assetUrlObj = new URL2(asset.url);
416
+ } catch (urlError) {
417
+ logger?.warn(`Cannot create URL object for "${asset.url}", skipping fetch. Error: ${urlError instanceof Error ? urlError.message : String(urlError)}`);
418
+ finalAssetsMap.set(asset.url, { ...asset, content: void 0 });
419
+ continue;
420
+ }
421
+ if (assetUrlObj) {
422
+ assetContentBuffer = await fetchAsset(assetUrlObj, logger);
423
+ }
424
+ }
425
+ if (needsFetching && assetContentBuffer === null) {
426
+ logger?.debug(`Storing asset ${asset.url} without content due to fetch failure.`);
427
+ finalAssetsMap.set(asset.url, { ...asset, content: void 0 });
428
+ continue;
429
+ }
430
+ if (assetContentBuffer) {
431
+ const mimeInfo = guessMimeType(asset.url);
432
+ const effectiveMime = mimeInfo.mime || "application/octet-stream";
433
+ if (TEXT_ASSET_TYPES.has(asset.type)) {
434
+ let textContent;
435
+ let wasLossy = false;
436
+ try {
437
+ textContent = assetContentBuffer.toString("utf-8");
438
+ wasLossy = isUtf8DecodingLossy(assetContentBuffer, textContent);
439
+ } catch (e) {
440
+ textContent = void 0;
441
+ wasLossy = true;
442
+ }
443
+ if (!wasLossy && textContent !== void 0) {
444
+ if (embedAssets) {
445
+ finalContent = textContent;
446
+ } else {
447
+ finalContent = void 0;
448
+ }
449
+ if (asset.type === "css") {
450
+ cssContentForParsing = textContent;
451
+ }
452
+ } else {
453
+ logger?.warn(`Could not decode ${asset.type} ${asset.url} as valid UTF-8 text.${embedAssets ? " Falling back to base64 data URI." : ""}`);
454
+ cssContentForParsing = void 0;
455
+ if (embedAssets) {
456
+ finalContent = `data:${effectiveMime};base64,${assetContentBuffer.toString("base64")}`;
457
+ } else {
458
+ finalContent = void 0;
459
+ }
460
+ }
461
+ } else if (BINARY_ASSET_TYPES.has(asset.type)) {
462
+ if (embedAssets) {
463
+ finalContent = `data:${effectiveMime};base64,${assetContentBuffer.toString("base64")}`;
464
+ } else {
465
+ finalContent = void 0;
466
+ }
467
+ cssContentForParsing = void 0;
468
+ } else {
469
+ cssContentForParsing = void 0;
470
+ if (embedAssets) {
471
+ try {
472
+ const attemptedTextContent = assetContentBuffer.toString("utf-8");
473
+ if (isUtf8DecodingLossy(assetContentBuffer, attemptedTextContent)) {
474
+ logger?.warn(`Couldn't embed unclassified asset ${asset.url} as text due to invalid UTF-8 sequences. Falling back to base64 (octet-stream).`);
475
+ finalContent = `data:application/octet-stream;base64,${assetContentBuffer.toString("base64")}`;
476
+ } else {
477
+ finalContent = attemptedTextContent;
478
+ logger?.debug(`Successfully embedded unclassified asset ${asset.url} as text.`);
479
+ }
480
+ } catch (decodeError) {
481
+ logger?.warn(`Error during text decoding for unclassified asset ${asset.url}: ${decodeError instanceof Error ? decodeError.message : String(decodeError)}. Falling back to base64.`);
482
+ finalContent = `data:application/octet-stream;base64,${assetContentBuffer.toString("base64")}`;
483
+ }
484
+ } else {
485
+ finalContent = void 0;
486
+ }
487
+ }
488
+ } else {
489
+ finalContent = void 0;
490
+ cssContentForParsing = void 0;
491
+ }
492
+ finalAssetsMap.set(asset.url, { ...asset, url: asset.url, content: finalContent });
493
+ if (asset.type === "css" && cssContentForParsing) {
494
+ const cssBaseContextUrl = determineBaseUrl(asset.url, logger);
495
+ logger?.debug(`CSS base context for resolving nested assets within ${asset.url}: ${cssBaseContextUrl}`);
496
+ if (cssBaseContextUrl) {
497
+ const newlyDiscoveredAssets = extractUrlsFromCSS(
498
+ cssContentForParsing,
499
+ cssBaseContextUrl,
500
+ logger
501
+ );
502
+ if (newlyDiscoveredAssets.length > 0) {
503
+ logger?.debug(`Discovered ${newlyDiscoveredAssets.length} nested assets in CSS ${asset.url}. Checking against queue...`);
504
+ for (const newAsset of newlyDiscoveredAssets) {
505
+ if (!processedOrQueuedUrls.has(newAsset.url)) {
506
+ processedOrQueuedUrls.add(newAsset.url);
507
+ assetsToProcess.push(newAsset);
508
+ logger?.debug(` -> Queued new nested asset: ${newAsset.url}`);
509
+ } else {
510
+ logger?.debug(` -> Skipping already processed/queued nested asset: ${newAsset.url}`);
511
+ }
512
+ }
513
+ }
514
+ } else {
515
+ logger?.warn(`Could not determine base URL context for CSS file ${asset.url}. Cannot resolve nested relative paths within it.`);
516
+ }
517
+ }
518
+ }
519
+ }
520
+ const finalIterationCount = iterationCount > MAX_ASSET_EXTRACTION_ITERATIONS ? "MAX+" : iterationCount;
521
+ logger?.info(`\u2705 Asset extraction COMPLETE! Found ${finalAssetsMap.size} unique assets in ${finalIterationCount} iterations.`);
522
+ return {
523
+ htmlContent: parsed.htmlContent,
524
+ assets: Array.from(finalAssetsMap.values())
525
+ };
526
+ }
527
+
528
+ // src/core/minifier.ts
529
+ import { minify as htmlMinify } from "html-minifier-terser";
530
+ import CleanCSS from "clean-css";
531
+ import { minify as jsMinify } from "terser";
532
+ var HTML_MINIFY_OPTIONS = {
533
+ collapseWhitespace: true,
534
+ removeComments: true,
535
+ conservativeCollapse: true,
536
+ minifyCSS: false,
537
+ // Handled separately
538
+ minifyJS: false,
539
+ // Handled separately
540
+ removeAttributeQuotes: false,
541
+ removeRedundantAttributes: true,
542
+ removeScriptTypeAttributes: true,
543
+ removeStyleLinkTypeAttributes: true,
544
+ useShortDoctype: true
545
+ };
546
+ var CSS_MINIFY_OPTIONS = {
547
+ returnPromise: false,
548
+ // <<< *** Ensures sync operation at runtime ***
549
+ level: {
550
+ 1: {
551
+ // Level 1 optimizations (safe transformations)
552
+ optimizeBackground: true,
553
+ optimizeBorderRadius: true,
554
+ optimizeFilter: true,
555
+ optimizeFontWeight: true,
556
+ optimizeOutline: true
557
+ },
558
+ 2: {
559
+ // Level 2 optimizations (structural changes, generally safe)
560
+ mergeMedia: true,
561
+ mergeNonAdjacentRules: true,
562
+ removeDuplicateFontRules: true,
563
+ removeDuplicateMediaBlocks: true,
564
+ removeDuplicateRules: true,
565
+ restructureRules: true
566
+ }
567
+ }
568
+ // Note: Type checking based on these options seems problematic with current @types/clean-css
569
+ };
570
+ var JS_MINIFY_OPTIONS = {
571
+ compress: {
572
+ dead_code: true,
573
+ drop_console: false,
574
+ drop_debugger: true,
575
+ ecma: 2020,
576
+ keep_classnames: true,
577
+ keep_fnames: true
578
+ },
579
+ mangle: {
580
+ keep_classnames: true,
581
+ keep_fnames: true
582
+ },
583
+ format: { comments: false }
584
+ };
585
+ async function minifyAssets(parsed, options = {}, logger) {
586
+ const { htmlContent, assets } = parsed;
587
+ const currentHtmlContent = htmlContent ?? "";
588
+ const currentAssets = assets ?? [];
589
+ if (!currentHtmlContent && currentAssets.length === 0) {
590
+ logger?.debug("Minification skipped: No content.");
591
+ return { htmlContent: currentHtmlContent, assets: currentAssets };
592
+ }
593
+ const minifyFlags = {
594
+ minifyHtml: options.minifyHtml !== false,
595
+ minifyCss: options.minifyCss !== false,
596
+ minifyJs: options.minifyJs !== false
597
+ };
598
+ logger?.debug(`Minification flags: ${JSON.stringify(minifyFlags)}`);
599
+ const minifiedAssets = await Promise.all(
600
+ currentAssets.map(async (asset) => {
601
+ let processedAsset = { ...asset };
602
+ if (typeof processedAsset.content !== "string" || processedAsset.content.length === 0) {
603
+ return processedAsset;
604
+ }
605
+ let newContent = processedAsset.content;
606
+ const assetIdentifier = processedAsset.url || `inline ${processedAsset.type}`;
607
+ try {
608
+ if (minifyFlags.minifyCss && processedAsset.type === "css") {
609
+ logger?.debug(`Minifying CSS: ${assetIdentifier}`);
610
+ const cssMinifier = new CleanCSS(CSS_MINIFY_OPTIONS);
611
+ const result = cssMinifier.minify(processedAsset.content);
612
+ if (result.errors && result.errors.length > 0) {
613
+ logger?.warn(`\u26A0\uFE0F CleanCSS failed for ${assetIdentifier}: ${result.errors.join(", ")}`);
614
+ } else {
615
+ if (result.warnings && result.warnings.length > 0) {
616
+ logger?.debug(`CleanCSS warnings for ${assetIdentifier}: ${result.warnings.join(", ")}`);
617
+ }
618
+ if (result.styles) {
619
+ newContent = result.styles;
620
+ logger?.debug(`CSS minified successfully: ${assetIdentifier}`);
621
+ } else {
622
+ logger?.warn(`\u26A0\uFE0F CleanCSS produced no styles but reported no errors for ${assetIdentifier}. Keeping original.`);
623
+ }
624
+ }
625
+ }
626
+ if (minifyFlags.minifyJs && processedAsset.type === "js") {
627
+ logger?.debug(`Minifying JS: ${assetIdentifier}`);
628
+ const result = await jsMinify(processedAsset.content, JS_MINIFY_OPTIONS);
629
+ if (result.code) {
630
+ newContent = result.code;
631
+ logger?.debug(`JS minified successfully: ${assetIdentifier}`);
632
+ } else {
633
+ const terserError = result.error;
634
+ if (terserError) {
635
+ logger?.warn(`\u26A0\uFE0F Terser failed for ${assetIdentifier}: ${terserError.message || terserError}`);
636
+ } else {
637
+ logger?.warn(`\u26A0\uFE0F Terser produced no code but reported no errors for ${assetIdentifier}. Keeping original.`);
638
+ }
639
+ }
640
+ }
641
+ } catch (err) {
642
+ const errorMessage = err instanceof Error ? err.message : String(err);
643
+ logger?.warn(`\u26A0\uFE0F Failed to minify asset ${assetIdentifier} (${processedAsset.type}): ${errorMessage}`);
644
+ }
645
+ processedAsset.content = newContent;
646
+ return processedAsset;
647
+ })
648
+ );
649
+ let finalHtml = currentHtmlContent;
650
+ if (minifyFlags.minifyHtml && finalHtml.length > 0) {
651
+ logger?.debug("Minifying HTML content...");
652
+ try {
653
+ finalHtml = await htmlMinify(finalHtml, {
654
+ ...HTML_MINIFY_OPTIONS,
655
+ minifyCSS: minifyFlags.minifyCss,
656
+ minifyJS: minifyFlags.minifyJs
657
+ });
658
+ logger?.debug("HTML minified successfully.");
659
+ } catch (err) {
660
+ const errorMessage = err instanceof Error ? err.message : String(err);
661
+ logger?.warn(`\u26A0\uFE0F HTML minification failed: ${errorMessage}`);
662
+ }
663
+ } else if (finalHtml.length > 0) {
664
+ logger?.debug("HTML minification skipped (disabled).");
665
+ }
666
+ return {
667
+ htmlContent: finalHtml,
668
+ assets: minifiedAssets
669
+ // The array of processed asset copies
670
+ };
671
+ }
672
+
673
+ // src/core/packer.ts
674
+ import * as cheerio2 from "cheerio";
675
+ function escapeScriptContent(code) {
676
+ return code.replace(/<\/(script)/gi, "<\\/$1");
677
+ }
678
+ function ensureBaseTag($, logger) {
679
+ let head = $("head");
680
+ if (head.length === 0) {
681
+ logger?.debug("No <head> tag found. Creating <head> and ensuring <html> exists.");
682
+ let htmlElement = $("html");
683
+ if (htmlElement.length === 0) {
684
+ logger?.debug("No <html> tag found. Wrapping content in <html><body>...");
685
+ const bodyContent = $.root().html() || "";
686
+ $.root().empty();
687
+ htmlElement = $("<html>").appendTo($.root());
688
+ head = $("<head>").appendTo(htmlElement);
689
+ $("<body>").html(bodyContent).appendTo(htmlElement);
690
+ } else {
691
+ head = $("<head>").prependTo(htmlElement);
692
+ }
693
+ }
694
+ if (head && head.length > 0 && head.find("base[href]").length === 0) {
695
+ logger?.debug('Prepending <base href="./"> to <head>.');
696
+ head.prepend('<base href="./">');
697
+ }
698
+ }
699
+ function inlineAssets($, assets, logger) {
700
+ logger?.debug(`Inlining ${assets.filter((a) => a.content).length} assets with content...`);
701
+ const assetMap = new Map(assets.map((asset) => [asset.url, asset]));
702
+ $('link[rel="stylesheet"][href]').each((_, el) => {
703
+ const link = $(el);
704
+ const href = link.attr("href");
705
+ const asset = href ? assetMap.get(href) : void 0;
706
+ if (asset?.content && typeof asset.content === "string") {
707
+ if (asset.content.startsWith("data:")) {
708
+ logger?.debug(`Replacing link with style tag using existing data URI: ${asset.url}`);
709
+ const styleTag = $("<style>").text(`@import url("${asset.content}");`);
710
+ link.replaceWith(styleTag);
711
+ } else {
712
+ logger?.debug(`Inlining CSS: ${asset.url}`);
713
+ const styleTag = $("<style>").text(asset.content);
714
+ link.replaceWith(styleTag);
715
+ }
716
+ } else if (href) {
717
+ logger?.warn(`Could not inline CSS: ${href}. Content missing or invalid.`);
718
+ }
719
+ });
720
+ $("script[src]").each((_, el) => {
721
+ const script = $(el);
722
+ const src = script.attr("src");
723
+ const asset = src ? assetMap.get(src) : void 0;
724
+ if (asset?.content && typeof asset.content === "string") {
725
+ logger?.debug(`Inlining JS: ${asset.url}`);
726
+ const inlineScript = $("<script>");
727
+ inlineScript.text(escapeScriptContent(asset.content));
728
+ Object.entries(script.attr() || {}).forEach(([key, value]) => {
729
+ if (key.toLowerCase() !== "src") inlineScript.attr(key, value);
730
+ });
731
+ script.replaceWith(inlineScript);
732
+ } else if (src) {
733
+ logger?.warn(`Could not inline JS: ${src}. Content missing or not string.`);
734
+ }
735
+ });
736
+ $('img[src], video[poster], input[type="image"][src]').each((_, el) => {
737
+ const element = $(el);
738
+ const srcAttr = element.is("video") ? "poster" : "src";
739
+ const src = element.attr(srcAttr);
740
+ const asset = src ? assetMap.get(src) : void 0;
741
+ if (asset?.content && typeof asset.content === "string" && asset.content.startsWith("data:")) {
742
+ logger?.debug(`Inlining image via ${srcAttr}: ${asset.url}`);
743
+ element.attr(srcAttr, asset.content);
744
+ } else if (src) {
745
+ logger?.warn(`Could not inline image via ${srcAttr}: ${src}. Content missing or not a data URI.`);
746
+ }
747
+ });
748
+ $("img[srcset], source[srcset]").each((_, el) => {
749
+ const element = $(el);
750
+ const srcset = element.attr("srcset");
751
+ if (!srcset) return;
752
+ const newSrcsetParts = [];
753
+ let changed = false;
754
+ srcset.split(",").forEach((part) => {
755
+ const trimmedPart = part.trim();
756
+ const [url, descriptor] = trimmedPart.split(/\s+/, 2);
757
+ const asset = url ? assetMap.get(url) : void 0;
758
+ if (asset?.content && typeof asset.content === "string" && asset.content.startsWith("data:")) {
759
+ newSrcsetParts.push(`${asset.content}${descriptor ? " " + descriptor : ""}`);
760
+ changed = true;
761
+ } else {
762
+ newSrcsetParts.push(trimmedPart);
763
+ }
764
+ });
765
+ if (changed) {
766
+ element.attr("srcset", newSrcsetParts.join(", "));
767
+ }
768
+ });
769
+ $("video[src], audio[src], video > source[src], audio > source[src]").each((_, el) => {
770
+ const element = $(el);
771
+ const src = element.attr("src");
772
+ const asset = src ? assetMap.get(src) : void 0;
773
+ if (asset?.content && typeof asset.content === "string" && asset.content.startsWith("data:")) {
774
+ logger?.debug(`Inlining media source: ${asset.url}`);
775
+ element.attr("src", asset.content);
776
+ }
777
+ });
778
+ logger?.debug("Asset inlining process complete.");
779
+ }
780
+ function packHTML(parsed, logger) {
781
+ const { htmlContent, assets } = parsed;
782
+ if (!htmlContent || typeof htmlContent !== "string") {
783
+ logger?.warn("Packer received empty or invalid htmlContent. Returning minimal HTML shell.");
784
+ return '<!DOCTYPE html><html><head><base href="./"></head><body></body></html>';
785
+ }
786
+ logger?.debug("Loading HTML content into Cheerio for packing...");
787
+ const $ = cheerio2.load(htmlContent);
788
+ logger?.debug("Ensuring <base> tag exists...");
789
+ ensureBaseTag($, logger);
790
+ logger?.debug("Starting asset inlining...");
791
+ inlineAssets($, assets, logger);
792
+ logger?.debug("Generating final packed HTML string...");
793
+ const finalHtml = $.html();
794
+ logger?.debug(`Packing complete. Final size: ${Buffer.byteLength(finalHtml)} bytes.`);
795
+ return finalHtml;
796
+ }
797
+
798
+ // src/core/web-fetcher.ts
799
+ import * as puppeteer from "puppeteer";
800
+ import * as fs2 from "fs/promises";
801
+
802
+ // src/types.ts
803
+ var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
804
+ LogLevel2[LogLevel2["NONE"] = 0] = "NONE";
805
+ LogLevel2[LogLevel2["ERROR"] = 1] = "ERROR";
806
+ LogLevel2[LogLevel2["WARN"] = 2] = "WARN";
807
+ LogLevel2[LogLevel2["INFO"] = 3] = "INFO";
808
+ LogLevel2[LogLevel2["DEBUG"] = 4] = "DEBUG";
809
+ return LogLevel2;
810
+ })(LogLevel || {});
811
+
812
+ // src/utils/logger.ts
813
+ var Logger = class _Logger {
814
+ /** The current minimum log level required for a message to be output. */
815
+ level;
816
+ /**
817
+ * Creates a new Logger instance.
818
+ * Defaults to LogLevel.INFO if no level is provided.
819
+ *
820
+ * @param {LogLevel} [level=LogLevel.INFO] - The initial log level for this logger instance.
821
+ * Must be one of the values from the LogLevel enum.
822
+ */
823
+ constructor(level = 3 /* INFO */) {
824
+ this.level = level !== void 0 && LogLevel[level] !== void 0 ? level : 3 /* INFO */;
825
+ }
826
+ /**
827
+ * Updates the logger's current level. Messages below this level will be suppressed.
828
+ *
829
+ * @param {LogLevel} level - The new log level to set. Must be a LogLevel enum member.
830
+ */
831
+ setLevel(level) {
832
+ this.level = level;
833
+ }
834
+ /**
835
+ * Logs a debug message if the current log level is DEBUG or higher.
836
+ *
837
+ * @param {string} message - The debug message string.
838
+ */
839
+ debug(message) {
840
+ if (this.level >= 4 /* DEBUG */) {
841
+ console.debug(`[DEBUG] ${message}`);
842
+ }
843
+ }
844
+ /**
845
+ * Logs an informational message if the current log level is INFO or higher.
846
+ *
847
+ * @param {string} message - The informational message string.
848
+ */
849
+ info(message) {
850
+ if (this.level >= 3 /* INFO */) {
851
+ console.info(`[INFO] ${message}`);
852
+ }
853
+ }
854
+ /**
855
+ * Logs a warning message if the current log level is WARN or higher.
856
+ *
857
+ * @param {string} message - The warning message string.
858
+ */
859
+ warn(message) {
860
+ if (this.level >= 2 /* WARN */) {
861
+ console.warn(`[WARN] ${message}`);
862
+ }
863
+ }
864
+ /**
865
+ * Logs an error message if the current log level is ERROR or higher.
866
+ *
867
+ * @param {string} message - The error message string.
868
+ */
869
+ error(message) {
870
+ if (this.level >= 1 /* ERROR */) {
871
+ console.error(`[ERROR] ${message}`);
872
+ }
873
+ }
874
+ /**
875
+ * Static factory method to create a Logger instance based on a simple boolean `verbose` flag.
876
+ *
877
+ * @static
878
+ * @param {{ verbose?: boolean }} [options={}] - An object potentially containing a `verbose` flag.
879
+ * @returns {Logger} A new Logger instance set to LogLevel.DEBUG if options.verbose is true,
880
+ * otherwise set to LogLevel.INFO.
881
+ */
882
+ static fromVerboseFlag(options = {}) {
883
+ return new _Logger(options.verbose ? 4 /* DEBUG */ : 3 /* INFO */);
884
+ }
885
+ /**
886
+ * Static factory method to create a Logger instance based on a LogLevel string name.
887
+ * Useful for creating a logger from config files or environments variables.
888
+ *
889
+ * @static
890
+ * @param {string | undefined} levelName - The name of the log level (e.g., 'debug', 'info', 'warn', 'error', 'silent'/'none'). Case-insensitive.
891
+ * @param {LogLevel} [defaultLevel=LogLevel.INFO] - The level to use if levelName is invalid or undefined.
892
+ * @returns {Logger} A new Logger instance set to the corresponding LogLevel.
893
+ */
894
+ static fromLevelName(levelName, defaultLevel = 3 /* INFO */) {
895
+ if (!levelName) {
896
+ return new _Logger(defaultLevel);
897
+ }
898
+ switch (levelName.toLowerCase()) {
899
+ // Return enum members
900
+ case "debug":
901
+ return new _Logger(4 /* DEBUG */);
902
+ case "info":
903
+ return new _Logger(3 /* INFO */);
904
+ case "warn":
905
+ return new _Logger(2 /* WARN */);
906
+ case "error":
907
+ return new _Logger(1 /* ERROR */);
908
+ case "silent":
909
+ case "none":
910
+ return new _Logger(0 /* NONE */);
911
+ default:
912
+ console.warn(`[Logger] Invalid log level name "${levelName}". Defaulting to ${LogLevel[defaultLevel]}.`);
913
+ return new _Logger(defaultLevel);
914
+ }
915
+ }
916
+ };
917
+
918
+ // src/utils/slugify.ts
919
+ function slugify(url) {
920
+ if (!url || typeof url !== "string") return "index";
921
+ let cleaned = url.trim();
922
+ let pathAndSearch = "";
923
+ try {
924
+ const urlObj = new URL(url, "https://placeholder.base");
925
+ pathAndSearch = (urlObj.pathname ?? "") + (urlObj.search ?? "");
926
+ } catch {
927
+ pathAndSearch = cleaned.split("#")[0];
928
+ }
929
+ try {
930
+ cleaned = decodeURIComponent(pathAndSearch);
931
+ } catch (e) {
932
+ cleaned = pathAndSearch;
933
+ }
934
+ cleaned = cleaned.replace(/\.(html?|php|aspx?|jsp)$/i, "").replace(/[\s/?=&\\]+/g, "-").replace(/[^\w._-]+/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
935
+ return cleaned || "index";
936
+ }
937
+ function sanitizeSlug(rawUrl) {
938
+ return slugify(rawUrl);
939
+ }
940
+
941
+ // src/core/bundler.ts
942
+ function bundleMultiPageHTML(pages, logger) {
943
+ if (!Array.isArray(pages)) {
944
+ const errorMsg = "Input pages must be an array of PageEntry objects";
945
+ logger?.error(errorMsg);
946
+ throw new Error(errorMsg);
947
+ }
948
+ logger?.info(`Bundling ${pages.length} pages into a multi-page HTML document.`);
949
+ const validPages = pages.filter((page) => {
950
+ const isValid = page && typeof page === "object" && typeof page.url === "string" && typeof page.html === "string";
951
+ if (!isValid) logger?.warn("Skipping invalid page entry");
952
+ return isValid;
953
+ });
954
+ if (validPages.length === 0) {
955
+ const errorMsg = "No valid page entries found in input array";
956
+ logger?.error(errorMsg);
957
+ throw new Error(errorMsg);
958
+ }
959
+ const slugMap = /* @__PURE__ */ new Map();
960
+ const usedSlugs = /* @__PURE__ */ new Set();
961
+ for (const page of validPages) {
962
+ const baseSlug = sanitizeSlug(page.url);
963
+ let slug = baseSlug;
964
+ let counter = 1;
965
+ while (usedSlugs.has(slug)) {
966
+ slug = `${baseSlug}-${counter++}`;
967
+ logger?.warn(`Slug collision detected for "${page.url}". Using "${slug}" instead.`);
968
+ }
969
+ usedSlugs.add(slug);
970
+ slugMap.set(page.url, slug);
971
+ }
972
+ const defaultPageSlug = slugMap.get(validPages[0].url);
973
+ let output = `<!DOCTYPE html>
974
+ <html lang="en">
975
+ <head>
976
+ <meta charset="UTF-8">
977
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
978
+ <title>Multi-Page Bundle</title>
979
+ </head>
980
+ <body>
981
+ <nav id="main-nav">
982
+ ${validPages.map((p) => {
983
+ const slug = slugMap.get(p.url);
984
+ const label = p.url.split("/").pop()?.split(".")[0] || "Page";
985
+ return `<a href="#${slug}" data-page="${slug}">${label}</a>`;
986
+ }).join("\n")}
987
+ </nav>
988
+ <div id="page-container"></div>
989
+ ${validPages.map((p) => {
990
+ const slug = slugMap.get(p.url);
991
+ return `<template id="page-${slug}">${p.html}</template>`;
992
+ }).join("\n")}
993
+ <script id="router-script">
994
+ document.addEventListener('DOMContentLoaded', function() {
995
+ function navigateTo(slug) {
996
+ const template = document.getElementById('page-' + slug);
997
+ const container = document.getElementById('page-container');
998
+ if (!template || !container) return;
999
+ container.innerHTML = '';
1000
+ container.appendChild(template.content.cloneNode(true));
1001
+ document.querySelectorAll('#main-nav a').forEach(link => {
1002
+ if (link.getAttribute('data-page') === slug) link.classList.add('active');
1003
+ else link.classList.remove('active');
1004
+ });
1005
+ if (window.location.hash.substring(1) !== slug) {
1006
+ history.pushState(null, '', '#' + slug);
1007
+ }
1008
+ }
1009
+
1010
+ window.addEventListener('hashchange', () => {
1011
+ const slug = window.location.hash.substring(1);
1012
+ if (document.getElementById('page-' + slug)) navigateTo(slug);
1013
+ });
1014
+
1015
+ document.querySelectorAll('#main-nav a').forEach(link => {
1016
+ link.addEventListener('click', function(e) {
1017
+ e.preventDefault();
1018
+ const slug = this.getAttribute('data-page');
1019
+ navigateTo(slug);
1020
+ });
1021
+ });
1022
+
1023
+ const initial = window.location.hash.substring(1);
1024
+ navigateTo(document.getElementById('page-' + initial) ? initial : '${defaultPageSlug}');
1025
+ });
1026
+ </script>
1027
+ </body>
1028
+ </html>`;
1029
+ logger?.info(`Multi-page bundle generated. Size: ${Buffer.byteLength(output, "utf-8")} bytes.`);
1030
+ return output;
1031
+ }
1032
+
1033
+ // src/core/web-fetcher.ts
1034
+ async function fetchAndPackWebPage(url, logger, timeout = 3e4) {
1035
+ let browser = null;
1036
+ const start = Date.now();
1037
+ logger?.debug(`Initiating fetch for single page: ${url}`);
1038
+ try {
1039
+ browser = await puppeteer.launch({ headless: true });
1040
+ logger?.debug(`Browser launched for ${url}`);
1041
+ const page = await browser.newPage();
1042
+ logger?.debug(`Page created for ${url}`);
1043
+ try {
1044
+ logger?.debug(`Navigating to ${url} with timeout ${timeout}ms`);
1045
+ await page.goto(url, { waitUntil: "networkidle2", timeout });
1046
+ logger?.debug(`Navigation successful for ${url}`);
1047
+ const html = await page.content();
1048
+ logger?.debug(`Content retrieved for ${url}`);
1049
+ const metadata = {
1050
+ input: url,
1051
+ outputSize: Buffer.byteLength(html, "utf-8"),
1052
+ assetCount: 0,
1053
+ // Basic fetch doesn't track assets
1054
+ buildTimeMs: Date.now() - start,
1055
+ errors: []
1056
+ // No errors if we reached this point
1057
+ };
1058
+ await page.close();
1059
+ logger?.debug(`Page closed for ${url}`);
1060
+ logger?.debug(`Browser closed for ${url}`);
1061
+ browser = null;
1062
+ return { html, metadata };
1063
+ } catch (pageError) {
1064
+ logger?.error(`Error during page processing for ${url}: ${pageError.message}`);
1065
+ try {
1066
+ await page.close();
1067
+ } catch (closeErr) {
1068
+ throw closeErr;
1069
+ }
1070
+ throw pageError;
1071
+ }
1072
+ } catch (launchError) {
1073
+ logger?.error(`Critical error during browser launch or page creation for ${url}: ${launchError.message}`);
1074
+ if (browser) {
1075
+ try {
1076
+ await browser.close();
1077
+ } catch (closeErr) {
1078
+ }
1079
+ }
1080
+ throw launchError;
1081
+ } finally {
1082
+ if (browser) {
1083
+ logger?.warn(`Closing browser in final cleanup for ${url}. This might indicate an unusual error path.`);
1084
+ try {
1085
+ await browser.close();
1086
+ } catch (closeErr) {
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+ async function crawlWebsite(startUrl, maxDepth, logger) {
1092
+ logger?.info(`Starting crawl for ${startUrl} with maxDepth ${maxDepth}`);
1093
+ if (maxDepth <= 0) {
1094
+ logger?.info("maxDepth is 0 or negative, no pages will be crawled.");
1095
+ return [];
1096
+ }
1097
+ const browser = await puppeteer.launch({ headless: true });
1098
+ const visited = /* @__PURE__ */ new Set();
1099
+ const results = [];
1100
+ const queue = [];
1101
+ let startOrigin;
1102
+ try {
1103
+ startOrigin = new URL(startUrl).origin;
1104
+ } catch (e) {
1105
+ logger?.error(`Invalid start URL: ${startUrl}. ${e.message}`);
1106
+ await browser.close();
1107
+ return [];
1108
+ }
1109
+ let normalizedStartUrl;
1110
+ try {
1111
+ const parsedStartUrl = new URL(startUrl);
1112
+ parsedStartUrl.hash = "";
1113
+ normalizedStartUrl = parsedStartUrl.href;
1114
+ } catch (e) {
1115
+ logger?.error(`Invalid start URL: ${startUrl}. ${e.message}`);
1116
+ await browser.close();
1117
+ return [];
1118
+ }
1119
+ visited.add(normalizedStartUrl);
1120
+ queue.push({ url: normalizedStartUrl, depth: 1 });
1121
+ logger?.debug(`Queued initial URL: ${normalizedStartUrl} (depth 1)`);
1122
+ while (queue.length > 0) {
1123
+ const { url, depth } = queue.shift();
1124
+ logger?.info(`Processing: ${url} (depth ${depth})`);
1125
+ let page = null;
1126
+ try {
1127
+ page = await browser.newPage();
1128
+ await page.setViewport({ width: 1280, height: 800 });
1129
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 3e4 });
1130
+ const html = await page.content();
1131
+ results.push({ url, html });
1132
+ logger?.debug(`Successfully fetched content for ${url}`);
1133
+ if (depth < maxDepth) {
1134
+ logger?.debug(`Discovering links on ${url} (current depth ${depth}, maxDepth ${maxDepth})`);
1135
+ const hrefs = await page.evaluate(
1136
+ () => Array.from(document.querySelectorAll("a[href]"), (a) => a.getAttribute("href"))
1137
+ );
1138
+ logger?.debug(`Found ${hrefs.length} potential hrefs on ${url}`);
1139
+ let linksAdded = 0;
1140
+ for (const href of hrefs) {
1141
+ if (!href) continue;
1142
+ let absoluteUrl;
1143
+ try {
1144
+ const resolved = new URL(href, url);
1145
+ resolved.hash = "";
1146
+ absoluteUrl = resolved.href;
1147
+ } catch (e) {
1148
+ logger?.debug(`Ignoring invalid URL syntax: "${href}" on page ${url}`);
1149
+ continue;
1150
+ }
1151
+ if (absoluteUrl.startsWith(startOrigin) && !visited.has(absoluteUrl)) {
1152
+ visited.add(absoluteUrl);
1153
+ queue.push({ url: absoluteUrl, depth: depth + 1 });
1154
+ linksAdded++;
1155
+ } else {
1156
+ }
1157
+ }
1158
+ logger?.debug(`Added ${linksAdded} new unique internal links to queue from ${url}`);
1159
+ } else {
1160
+ logger?.debug(`Max depth (${maxDepth}) reached, not discovering links on ${url}`);
1161
+ }
1162
+ } catch (err) {
1163
+ logger?.warn(`\u274C Failed to process ${url}: ${err.message}`);
1164
+ } finally {
1165
+ if (page) {
1166
+ try {
1167
+ await page.close();
1168
+ } catch (pageCloseError) {
1169
+ logger?.error(`Failed to close page for ${url}: ${pageCloseError.message}`);
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+ logger?.info(`Crawl finished. Closing browser.`);
1175
+ await browser.close();
1176
+ logger?.info(`Found ${results.length} pages.`);
1177
+ return results;
1178
+ }
1179
+ async function recursivelyBundleSite(startUrl, outputFile, maxDepth = 1) {
1180
+ const logger = new Logger();
1181
+ logger.info(`Starting recursive site bundle for ${startUrl} to ${outputFile} (maxDepth: ${maxDepth})`);
1182
+ try {
1183
+ const pages = await crawlWebsite(startUrl, maxDepth, logger);
1184
+ if (pages.length === 0) {
1185
+ logger.warn("Crawl completed but found 0 pages. Output file may be empty or reflect an empty bundle.");
1186
+ } else {
1187
+ logger.info(`Crawl successful, found ${pages.length} pages. Starting bundling.`);
1188
+ }
1189
+ const bundledHtml = bundleMultiPageHTML(pages, logger);
1190
+ logger.info(`Bundling complete. Output size: ${Buffer.byteLength(bundledHtml, "utf-8")} bytes.`);
1191
+ logger.info(`Writing bundled HTML to ${outputFile}`);
1192
+ await fs2.writeFile(outputFile, bundledHtml, "utf-8");
1193
+ logger.info(`Successfully wrote bundled output to ${outputFile}`);
1194
+ return {
1195
+ pages: pages.length,
1196
+ html: bundledHtml
1197
+ };
1198
+ } catch (error) {
1199
+ logger.error(`Error during recursive site bundle: ${error.message}`);
1200
+ if (error.stack) {
1201
+ logger.error(`Stack trace: ${error.stack}`);
1202
+ }
1203
+ throw error;
1204
+ }
1205
+ }
1206
+
1207
+ // src/utils/meta.ts
1208
+ var BuildTimer = class {
1209
+ startTime;
1210
+ input;
1211
+ pagesBundled;
1212
+ // Tracks pages for recursive bundles
1213
+ assetCount = 0;
1214
+ // Tracks discovered/processed assets
1215
+ errors = [];
1216
+ // Collects warnings/errors
1217
+ /**
1218
+ * Creates and starts a build timer session for a given input.
1219
+ *
1220
+ * @param {string} input - The source file path or URL being processed.
1221
+ */
1222
+ constructor(input) {
1223
+ this.startTime = Date.now();
1224
+ this.input = input;
1225
+ }
1226
+ /**
1227
+ * Explicitly sets the number of assets discovered or processed.
1228
+ * This might be called after asset extraction/minification.
1229
+ *
1230
+ * @param {number} count - The total number of assets.
1231
+ */
1232
+ setAssetCount(count) {
1233
+ this.assetCount = count;
1234
+ }
1235
+ /**
1236
+ * Records a warning or error message encountered during the build.
1237
+ * These are added to the final metadata.
1238
+ *
1239
+ * @param {string} message - The warning or error description.
1240
+ */
1241
+ addError(message) {
1242
+ this.errors.push(message);
1243
+ }
1244
+ /**
1245
+ * Sets the number of pages bundled, typically used in multi-page
1246
+ * or recursive bundling scenarios.
1247
+ *
1248
+ * @param {number} count - The number of HTML pages included in the bundle.
1249
+ */
1250
+ setPageCount(count) {
1251
+ this.pagesBundled = count;
1252
+ }
1253
+ /**
1254
+ * Stops the timer, calculates final metrics, and returns the complete
1255
+ * BundleMetadata object. Merges any explicitly provided metadata
1256
+ * (like assetCount calculated elsewhere) with the timer's tracked data.
1257
+ *
1258
+ * @param {string} finalHtml - The final generated HTML string, used to calculate output size.
1259
+ * @param {Partial<BundleMetadata>} [extra] - Optional object containing metadata fields
1260
+ * (like assetCount or pre-calculated errors) that should override the timer's internal values.
1261
+ * @returns {BundleMetadata} The finalized metadata object for the build process.
1262
+ */
1263
+ finish(html, extra) {
1264
+ const buildTimeMs = Date.now() - this.startTime;
1265
+ const outputSize = Buffer.byteLength(html || "", "utf-8");
1266
+ const combinedErrors = Array.from(/* @__PURE__ */ new Set([...this.errors, ...extra?.errors ?? []]));
1267
+ const finalMetadata = {
1268
+ input: this.input,
1269
+ outputSize,
1270
+ buildTimeMs,
1271
+ assetCount: extra?.assetCount ?? this.assetCount,
1272
+ pagesBundled: extra?.pagesBundled ?? this.pagesBundled,
1273
+ // Assign the combined errors array
1274
+ errors: combinedErrors
1275
+ };
1276
+ if (finalMetadata.pagesBundled === void 0) {
1277
+ delete finalMetadata.pagesBundled;
1278
+ }
1279
+ if (finalMetadata.errors?.length === 0) {
1280
+ delete finalMetadata.errors;
1281
+ }
1282
+ return finalMetadata;
1283
+ }
1284
+ };
1285
+
1286
+ // src/index.ts
1287
+ async function generatePortableHTML(input, options = {}, loggerInstance) {
1288
+ const logger = loggerInstance || new Logger(options.logLevel);
1289
+ logger.info(`Generating portable HTML for: ${input}`);
1290
+ const timer = new BuildTimer(input);
1291
+ const isRemote = /^https?:\/\//i.test(input);
1292
+ if (isRemote) {
1293
+ logger.info(`Input is a remote URL. Fetching page content directly...`);
1294
+ try {
1295
+ const result = await fetchAndPackWebPage2(input, options, logger);
1296
+ logger.info(`Remote fetch complete. Input: ${input}, Size: ${result.metadata.outputSize} bytes, Time: ${result.metadata.buildTimeMs}ms`);
1297
+ return result;
1298
+ } catch (error) {
1299
+ logger.error(`Failed to fetch remote URL ${input}: ${error.message}`);
1300
+ throw error;
1301
+ }
1302
+ }
1303
+ logger.info(`Input is a local file path. Starting local processing pipeline...`);
1304
+ const basePath = options.baseUrl || input;
1305
+ logger.debug(`Using base path for asset resolution: ${basePath}`);
1306
+ try {
1307
+ const parsed = await parseHTML(input, logger);
1308
+ const enriched = await extractAssets(parsed, options.embedAssets ?? true, basePath, logger);
1309
+ const minified = await minifyAssets(enriched, options, logger);
1310
+ const finalHtml = packHTML(minified, logger);
1311
+ const metadata = timer.finish(finalHtml, {
1312
+ assetCount: minified.assets.length
1313
+ // FIX: Removed incorrect attempt to get errors from logger
1314
+ // Errors collected by the timer itself (via timer.addError) will be included automatically.
1315
+ });
1316
+ logger.info(`Local processing complete. Input: ${input}, Size: ${metadata.outputSize} bytes, Assets: ${metadata.assetCount}, Time: ${metadata.buildTimeMs}ms`);
1317
+ if (metadata.errors && metadata.errors.length > 0) {
1318
+ logger.warn(`Completed with ${metadata.errors.length} warning(s) logged in metadata.`);
1319
+ }
1320
+ return { html: finalHtml, metadata };
1321
+ } catch (error) {
1322
+ logger.error(`Error during local processing for ${input}: ${error.message}`);
1323
+ throw error;
1324
+ }
1325
+ }
1326
+ async function generateRecursivePortableHTML(url, depth = 1, options = {}, loggerInstance) {
1327
+ const logger = loggerInstance || new Logger(options.logLevel);
1328
+ logger.info(`Generating recursive portable HTML for: ${url}, Max Depth: ${depth}`);
1329
+ const timer = new BuildTimer(url);
1330
+ if (!/^https?:\/\//i.test(url)) {
1331
+ const errMsg = `Invalid input URL for recursive bundling: ${url}. Must start with http(s)://`;
1332
+ logger.error(errMsg);
1333
+ throw new Error(errMsg);
1334
+ }
1335
+ const internalOutputPathPlaceholder = `${new URL(url).hostname}_recursive.html`;
1336
+ try {
1337
+ const { html, pages } = await recursivelyBundleSite(url, internalOutputPathPlaceholder, depth);
1338
+ logger.info(`Recursive crawl complete. Discovered and bundled ${pages} pages.`);
1339
+ timer.setPageCount(pages);
1340
+ const metadata = timer.finish(html, {
1341
+ assetCount: 0,
1342
+ // NOTE: Asset count across multiple pages is not currently aggregated.
1343
+ pagesBundled: pages
1344
+ // TODO: Potentially collect errors from the core function if it returns them
1345
+ });
1346
+ logger.info(`Recursive bundling complete. Input: ${url}, Size: ${metadata.outputSize} bytes, Pages: ${metadata.pagesBundled}, Time: ${metadata.buildTimeMs}ms`);
1347
+ if (metadata.errors && metadata.errors.length > 0) {
1348
+ logger.warn(`Completed with ${metadata.errors.length} warning(s) logged in metadata.`);
1349
+ }
1350
+ return { html, metadata };
1351
+ } catch (error) {
1352
+ logger.error(`Error during recursive generation for ${url}: ${error.message}`);
1353
+ if (error.cause instanceof Error) {
1354
+ logger.error(`Cause: ${error.cause.message}`);
1355
+ }
1356
+ throw error;
1357
+ }
1358
+ }
1359
+ async function fetchAndPackWebPage2(url, options = {}, loggerInstance) {
1360
+ const logger = loggerInstance || new Logger(options.logLevel);
1361
+ logger.info(`Workspaceing single remote page: ${url}`);
1362
+ const timer = new BuildTimer(url);
1363
+ if (!/^https?:\/\//i.test(url)) {
1364
+ const errMsg = `Invalid input URL for fetchAndPackWebPage: ${url}. Must start with http(s)://`;
1365
+ logger.error(errMsg);
1366
+ throw new Error(errMsg);
1367
+ }
1368
+ try {
1369
+ const result = await fetchAndPackWebPage(url, logger);
1370
+ const metadata = timer.finish(result.html, {
1371
+ // Use assetCount and errors from core metadata if available
1372
+ assetCount: result.metadata?.assetCount ?? 0,
1373
+ errors: result.metadata?.errors ?? []
1374
+ // Ensure errors array exists
1375
+ });
1376
+ logger.info(`Single page fetch complete. Input: ${url}, Size: ${metadata.outputSize} bytes, Assets: ${metadata.assetCount}, Time: ${metadata.buildTimeMs}ms`);
1377
+ if (metadata.errors && metadata.errors.length > 0) {
1378
+ logger.warn(`Completed with ${metadata.errors.length} warning(s) logged in metadata.`);
1379
+ }
1380
+ return { html: result.html, metadata };
1381
+ } catch (error) {
1382
+ logger.error(`Error during single page fetch for ${url}: ${error.message}`);
1383
+ throw error;
1384
+ }
1385
+ }
1386
+ function bundleMultiPageHTML2(pages, options = {}, loggerInstance) {
1387
+ const logger = loggerInstance || new Logger(options.logLevel);
1388
+ logger.info(`Bundling ${pages.length} provided pages into multi-page HTML...`);
1389
+ try {
1390
+ const bundledHtml = bundleMultiPageHTML(pages, logger);
1391
+ logger.info(`Multi-page bundling complete.`);
1392
+ return bundledHtml;
1393
+ } catch (error) {
1394
+ logger.error(`Error during multi-page bundling: ${error.message}`);
1395
+ throw error;
1396
+ }
1397
+ }
1398
+ export {
1399
+ LogLevel,
1400
+ bundleMultiPageHTML2 as bundleMultiPageHTML,
1401
+ fetchAndPackWebPage2 as fetchAndPackWebPage,
1402
+ generatePortableHTML,
1403
+ generateRecursivePortableHTML
1404
+ };
1405
+ //# sourceMappingURL=index.js.map