portapack 0.2.1 → 0.3.0
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.
- package/CHANGELOG.md +12 -0
- package/README.md +83 -216
- package/dist/cli/{cli-entry.js → cli-entry.cjs} +626 -498
- package/dist/cli/cli-entry.cjs.map +1 -0
- package/dist/index.d.ts +51 -56
- package/dist/index.js +523 -443
- package/dist/index.js.map +1 -1
- package/docs/cli.md +158 -42
- package/jest.config.ts +18 -8
- package/jest.setup.cjs +66 -146
- package/package.json +5 -5
- package/src/cli/cli-entry.ts +15 -15
- package/src/cli/cli.ts +130 -119
- package/src/core/bundler.ts +174 -63
- package/src/core/extractor.ts +243 -203
- package/src/core/web-fetcher.ts +205 -141
- package/src/index.ts +161 -224
- package/tests/unit/cli/cli-entry.test.ts +66 -77
- package/tests/unit/cli/cli.test.ts +243 -145
- package/tests/unit/core/bundler.test.ts +334 -258
- package/tests/unit/core/extractor.test.ts +391 -1051
- package/tests/unit/core/minifier.test.ts +130 -221
- package/tests/unit/core/packer.test.ts +255 -106
- package/tests/unit/core/parser.test.ts +89 -458
- package/tests/unit/core/web-fetcher.test.ts +330 -285
- package/tests/unit/index.test.ts +206 -300
- package/tests/unit/utils/logger.test.ts +32 -28
- package/tsconfig.jest.json +7 -7
- package/tsup.config.ts +34 -29
- package/dist/cli/cli-entry.js.map +0 -1
- package/output.html +0 -1
- package/site-packed.html +0 -1
- package/test-output.html +0 -0
package/src/core/bundler.ts
CHANGED
@@ -1,17 +1,17 @@
|
|
1
1
|
/**
|
2
2
|
* @file bundler.ts
|
3
3
|
* @description Core bundling functions to handle both single and multi-page HTML documents. This includes asset extraction, optional minification, and full inlining into a self-contained HTML file.
|
4
|
-
* @version 1.3.0
|
4
|
+
* @version 1.3.0 // Assuming version based on previous context
|
5
5
|
*/
|
6
6
|
|
7
|
-
import { dirname, resolve } from 'path';
|
7
|
+
import { dirname, resolve, sep as pathSeparator } from 'path';
|
8
8
|
import { pathToFileURL, URL } from 'url';
|
9
|
-
import { extractAssets } from './extractor
|
10
|
-
import { minifyAssets } from './minifier
|
11
|
-
import { packHTML } from './packer
|
12
|
-
import { Logger } from '../utils/logger
|
13
|
-
import { ParsedHTML, BundleOptions, PageEntry } from '../types
|
14
|
-
import { sanitizeSlug
|
9
|
+
import { extractAssets } from './extractor';
|
10
|
+
import { minifyAssets } from './minifier';
|
11
|
+
import { packHTML } from './packer';
|
12
|
+
import { Logger } from '../utils/logger';
|
13
|
+
import { ParsedHTML, BundleOptions, PageEntry, LogLevel } from '../types'; // Added LogLevel import
|
14
|
+
import { sanitizeSlug } from '../utils/slugify';
|
15
15
|
|
16
16
|
/**
|
17
17
|
* Determines the appropriate base URL for resolving relative assets
|
@@ -25,20 +25,26 @@ function determineBaseUrl(input: string, logger?: Logger): string {
|
|
25
25
|
try {
|
26
26
|
if (input.startsWith('http://') || input.startsWith('https://')) {
|
27
27
|
const url = new URL(input);
|
28
|
+
// Go up to the last '/' in the pathname
|
28
29
|
url.pathname = url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1);
|
29
|
-
url.search = '';
|
30
|
-
url.hash = '';
|
30
|
+
url.search = ''; // Remove query string
|
31
|
+
url.hash = ''; // Remove fragment
|
31
32
|
const baseUrl = url.toString();
|
32
33
|
logger?.debug(`Determined remote base URL: ${baseUrl}`);
|
33
34
|
return baseUrl;
|
34
35
|
} else {
|
36
|
+
// Handle local file path
|
35
37
|
const absoluteDir = dirname(resolve(input));
|
36
|
-
|
38
|
+
// Ensure trailing separator for directory URL conversion
|
39
|
+
const dirPathWithSeparator = absoluteDir.endsWith(pathSeparator) ? absoluteDir : absoluteDir + pathSeparator;
|
40
|
+
const baseUrl = pathToFileURL(dirPathWithSeparator).href;
|
37
41
|
logger?.debug(`Determined local base URL: ${baseUrl}`);
|
38
42
|
return baseUrl;
|
39
43
|
}
|
40
44
|
} catch (error: any) {
|
41
|
-
logger?.error
|
45
|
+
// Use logger?.error correctly
|
46
|
+
logger?.error(`💀 Failed to determine base URL for "${input}": ${error.message}`);
|
47
|
+
// Return a default relative base URL on error
|
42
48
|
return './';
|
43
49
|
}
|
44
50
|
}
|
@@ -47,48 +53,58 @@ function determineBaseUrl(input: string, logger?: Logger): string {
|
|
47
53
|
* Creates a self-contained HTML file from a parsed HTML structure and options.
|
48
54
|
*
|
49
55
|
* @param parsedHtml - The parsed HTML document.
|
50
|
-
* @param
|
56
|
+
* @param inputPathOrUrl - The original input file or URL for base URL calculation.
|
51
57
|
* @param options - Optional bundling options.
|
52
58
|
* @param logger - Optional logger instance.
|
53
59
|
* @returns A fully inlined and bundled HTML string.
|
54
60
|
*/
|
55
61
|
export async function bundleSingleHTML(
|
56
62
|
parsedHtml: ParsedHTML,
|
57
|
-
|
63
|
+
inputPathOrUrl: string, // Renamed parameter for clarity
|
58
64
|
options: BundleOptions = {},
|
59
65
|
logger?: Logger
|
60
66
|
): Promise<string> {
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
if (!mergedOptions.baseUrl) {
|
78
|
-
mergedOptions.baseUrl = determineBaseUrl(inputPath, logger);
|
79
|
-
}
|
67
|
+
// Define comprehensive defaults
|
68
|
+
const defaultOptions: Required<Omit<BundleOptions, 'logLevel'|'loggerInstance'>> = { // Omit non-serializable/runtime options from defaults
|
69
|
+
embedAssets: true,
|
70
|
+
minifyHtml: true,
|
71
|
+
minifyJs: true,
|
72
|
+
minifyCss: true,
|
73
|
+
baseUrl: '',
|
74
|
+
verbose: false, // Default verbosity usually controlled by logger level
|
75
|
+
dryRun: false,
|
76
|
+
recursive: false, // Default non-recursive for single bundle
|
77
|
+
output: '', // Default handled elsewhere or not relevant here
|
78
|
+
// Omit logLevel from defaults, use logger?.level
|
79
|
+
};
|
80
|
+
|
81
|
+
// Merge provided options over defaults
|
82
|
+
const mergedOptions = { ...defaultOptions, ...options };
|
80
83
|
|
81
|
-
|
82
|
-
|
84
|
+
// Determine base URL only if not explicitly provided
|
85
|
+
if (!mergedOptions.baseUrl) {
|
86
|
+
mergedOptions.baseUrl = determineBaseUrl(inputPathOrUrl, logger);
|
87
|
+
}
|
88
|
+
|
89
|
+
try {
|
90
|
+
logger?.debug(`Starting HTML bundling for ${inputPathOrUrl}`);
|
91
|
+
// Use logger?.level safely
|
92
|
+
const effectiveLogLevel = (logger && typeof logger.level === 'number') ? logger.level : LogLevel.INFO; // Default to INFO if logger undefined or level wrong type
|
93
|
+
logger?.debug(`Effective options: ${JSON.stringify({
|
94
|
+
...mergedOptions,
|
95
|
+
logLevel: effectiveLogLevel // Include actual log level if needed
|
96
|
+
}, null, 2)}`);
|
83
97
|
|
98
|
+
// Execute the bundling pipeline
|
84
99
|
const extracted = await extractAssets(parsedHtml, mergedOptions.embedAssets, mergedOptions.baseUrl, logger);
|
85
100
|
const minified = await minifyAssets(extracted, mergedOptions, logger);
|
86
101
|
const result = packHTML(minified, logger);
|
87
102
|
|
88
|
-
logger?.info(`Single HTML bundling complete for: ${
|
103
|
+
logger?.info(`Single HTML bundling complete for: ${inputPathOrUrl}`);
|
89
104
|
return result;
|
90
105
|
} catch (error: any) {
|
91
|
-
logger?.error(`Error during single HTML bundling: ${error.message}`);
|
106
|
+
logger?.error(`Error during single HTML bundling for ${inputPathOrUrl}: ${error.message}`);
|
107
|
+
// Re-throw to allow higher-level handling
|
92
108
|
throw error;
|
93
109
|
}
|
94
110
|
}
|
@@ -110,9 +126,12 @@ export function bundleMultiPageHTML(pages: PageEntry[], logger?: Logger): string
|
|
110
126
|
|
111
127
|
logger?.info(`Bundling ${pages.length} pages into a multi-page HTML document.`);
|
112
128
|
|
129
|
+
let pageIndex = 0; // Keep track of original index for logging
|
113
130
|
const validPages = pages.filter(page => {
|
114
131
|
const isValid = page && typeof page === 'object' && typeof page.url === 'string' && typeof page.html === 'string';
|
115
|
-
|
132
|
+
// Log with original index if invalid
|
133
|
+
if (!isValid) logger?.warn(`Skipping invalid page entry at index ${pageIndex}`);
|
134
|
+
pageIndex++; // Increment index regardless
|
116
135
|
return isValid;
|
117
136
|
});
|
118
137
|
|
@@ -124,73 +143,165 @@ export function bundleMultiPageHTML(pages: PageEntry[], logger?: Logger): string
|
|
124
143
|
|
125
144
|
const slugMap = new Map<string, string>();
|
126
145
|
const usedSlugs = new Set<string>();
|
146
|
+
let firstValidSlug: string | undefined = undefined;
|
147
|
+
let pageCounterForFallback = 1; // Counter for unique fallback slugs
|
127
148
|
|
128
149
|
for (const page of validPages) {
|
129
|
-
|
150
|
+
// --- REVISED SLUG LOGIC ---
|
151
|
+
let baseSlug = sanitizeSlug(page.url);
|
152
|
+
|
153
|
+
// Determine if the URL represents a root index page
|
154
|
+
const isRootIndex = (page.url === '/' || page.url === 'index.html' || page.url.endsWith('/index.html'));
|
155
|
+
|
156
|
+
if (baseSlug === 'index' && !isRootIndex) {
|
157
|
+
// If sanitizeSlug produced 'index' but it wasn't from a root index URL, avoid using 'index'.
|
158
|
+
logger?.debug(`URL "${page.url}" sanitized to "index", attempting to find alternative slug.`);
|
159
|
+
// Try using the last path segment instead.
|
160
|
+
// Get parts, remove trailing slash/index/index.html, filter empty
|
161
|
+
const pathParts = page.url.replace(/\/$/, '').split('/').filter(p => p && p.toLowerCase() !== 'index.html' && p.toLowerCase() !== 'index');
|
162
|
+
if (pathParts.length > 0) {
|
163
|
+
const lastPartSlug = sanitizeSlug(pathParts[pathParts.length - 1]);
|
164
|
+
if (lastPartSlug && lastPartSlug !== 'index') { // Avoid re-introducing 'index' or using empty
|
165
|
+
baseSlug = lastPartSlug;
|
166
|
+
logger?.debug(`Using last path part slug "${baseSlug}" instead.`);
|
167
|
+
} else {
|
168
|
+
baseSlug = 'page'; // Fallback if last part is empty, 'index', or missing
|
169
|
+
logger?.debug(`Last path part invalid ("${lastPartSlug}"), using fallback slug "page".`);
|
170
|
+
}
|
171
|
+
} else {
|
172
|
+
baseSlug = 'page'; // Fallback if no other parts
|
173
|
+
logger?.debug(`No valid path parts found, using fallback slug "page".`);
|
174
|
+
}
|
175
|
+
} else if (!baseSlug) {
|
176
|
+
// Handle cases where sanitizeSlug returns an empty string initially (e.g. sanitizeSlug('/'))
|
177
|
+
if (isRootIndex) {
|
178
|
+
baseSlug = 'index'; // Ensure root index gets 'index' slug even if sanitizeSlug returns empty
|
179
|
+
logger?.debug(`URL "${page.url}" sanitized to empty string, using "index" as it is a root index.`);
|
180
|
+
} else {
|
181
|
+
baseSlug = 'page'; // Fallback for other empty slugs
|
182
|
+
logger?.debug(`URL "${page.url}" sanitized to empty string, using fallback slug "page".`);
|
183
|
+
}
|
184
|
+
}
|
185
|
+
// Ensure baseSlug is never empty after this point before collision check
|
186
|
+
if (!baseSlug) {
|
187
|
+
// Use a counter to ensure uniqueness if multiple pages sanitize to empty/page
|
188
|
+
baseSlug = `page-${pageCounterForFallback++}`;
|
189
|
+
logger?.warn(`Could not determine a valid base slug for "${page.url}", using generated fallback "${baseSlug}".`);
|
190
|
+
}
|
191
|
+
// --- END REVISED SLUG LOGIC ---
|
192
|
+
|
193
|
+
|
194
|
+
// --- Collision Handling ---
|
130
195
|
let slug = baseSlug;
|
131
|
-
let
|
196
|
+
let collisionCounter = 1;
|
197
|
+
// Keep track of the original baseSlug for logging purposes in case of collision
|
198
|
+
const originalBaseSlugForLog = baseSlug;
|
132
199
|
while (usedSlugs.has(slug)) {
|
133
|
-
|
134
|
-
|
200
|
+
const newSlug = `${originalBaseSlugForLog}-${collisionCounter++}`;
|
201
|
+
// Log with original intended base slug for clarity
|
202
|
+
logger?.warn(`Slug collision detected for "${page.url}" (intended slug: '${originalBaseSlugForLog}'). Using "${newSlug}" instead.`);
|
203
|
+
slug = newSlug;
|
135
204
|
}
|
136
205
|
usedSlugs.add(slug);
|
137
206
|
slugMap.set(page.url, slug);
|
207
|
+
|
208
|
+
// Track the first valid slug for default navigation
|
209
|
+
if (firstValidSlug === undefined) { // Use triple equals for check
|
210
|
+
firstValidSlug = slug;
|
211
|
+
}
|
138
212
|
}
|
139
213
|
|
140
|
-
|
214
|
+
// Determine the default page slug - prefer 'index' if present, otherwise use the first page's slug
|
215
|
+
// Use 'page' as ultimate fallback if firstValidSlug is somehow still undefined (e.g., only one page failed slug generation)
|
216
|
+
const defaultPageSlug = usedSlugs.has('index') ? 'index' : (firstValidSlug || 'page');
|
141
217
|
|
218
|
+
// Generate HTML structure
|
219
|
+
// (Ensure template IDs use `page-${slug}`)
|
220
|
+
// (Ensure nav links use `href="#${slug}"` and `data-page="${slug}"`)
|
221
|
+
// (Ensure router script uses `${defaultPageSlug}` correctly)
|
142
222
|
let output = `<!DOCTYPE html>
|
143
223
|
<html lang="en">
|
144
224
|
<head>
|
145
225
|
<meta charset="UTF-8">
|
146
226
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
147
227
|
<title>Multi-Page Bundle</title>
|
228
|
+
<style>
|
229
|
+
body { font-family: sans-serif; margin: 0; }
|
230
|
+
#main-nav { background-color: #f0f0f0; padding: 10px; border-bottom: 1px solid #ccc; }
|
231
|
+
#main-nav a { margin-right: 15px; text-decoration: none; color: #007bff; }
|
232
|
+
#main-nav a.active { font-weight: bold; text-decoration: underline; }
|
233
|
+
#page-container { padding: 20px; }
|
234
|
+
template { display: none; }
|
235
|
+
</style>
|
148
236
|
</head>
|
149
237
|
<body>
|
150
238
|
<nav id="main-nav">
|
151
239
|
${validPages.map(p => {
|
152
|
-
const slug = slugMap.get(p.url)!;
|
153
|
-
const label =
|
240
|
+
const slug = slugMap.get(p.url)!; // Slug is guaranteed to exist here
|
241
|
+
const label = slug; // Use slug as label for simplicity
|
154
242
|
return `<a href="#${slug}" data-page="${slug}">${label}</a>`;
|
155
|
-
}).join('\n')}
|
243
|
+
}).join('\n ')}
|
156
244
|
</nav>
|
157
245
|
<div id="page-container"></div>
|
158
246
|
${validPages.map(p => {
|
159
247
|
const slug = slugMap.get(p.url)!;
|
248
|
+
// Basic sanitization/escaping might be needed for p.html if needed
|
160
249
|
return `<template id="page-${slug}">${p.html}</template>`;
|
161
|
-
}).join('\n')}
|
250
|
+
}).join('\n ')}
|
162
251
|
<script id="router-script">
|
163
252
|
document.addEventListener('DOMContentLoaded', function() {
|
253
|
+
const pageContainer = document.getElementById('page-container');
|
254
|
+
const navLinks = document.querySelectorAll('#main-nav a');
|
255
|
+
|
164
256
|
function navigateTo(slug) {
|
165
257
|
const template = document.getElementById('page-' + slug);
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
258
|
+
if (!template || !pageContainer) {
|
259
|
+
console.warn('Navigation failed: Template or container not found for slug:', slug);
|
260
|
+
// Maybe try navigating to default page? Or just clear container?
|
261
|
+
if (pageContainer) pageContainer.innerHTML = '<p>Page not found.</p>';
|
262
|
+
return;
|
263
|
+
}
|
264
|
+
// Clear previous content and append new content
|
265
|
+
pageContainer.innerHTML = ''; // Clear reliably
|
266
|
+
pageContainer.appendChild(template.content.cloneNode(true));
|
267
|
+
|
268
|
+
// Update active link styling
|
269
|
+
navLinks.forEach(link => {
|
270
|
+
link.classList.toggle('active', link.getAttribute('data-page') === slug);
|
173
271
|
});
|
272
|
+
|
273
|
+
// Update URL hash without triggering hashchange if already correct
|
174
274
|
if (window.location.hash.substring(1) !== slug) {
|
175
|
-
|
275
|
+
// Use pushState for cleaner history
|
276
|
+
history.pushState({ slug: slug }, '', '#' + slug);
|
176
277
|
}
|
177
278
|
}
|
178
279
|
|
179
|
-
|
180
|
-
|
181
|
-
|
280
|
+
// Handle back/forward navigation
|
281
|
+
window.addEventListener('popstate', (event) => {
|
282
|
+
let slug = window.location.hash.substring(1);
|
283
|
+
// If popstate event has state use it, otherwise fallback to hash or default
|
284
|
+
if (event && event.state && event.state.slug) { // Check event exists
|
285
|
+
slug = event.state.slug;
|
286
|
+
}
|
287
|
+
// Ensure the target page exists before navigating, fallback to default slug
|
288
|
+
const targetSlug = document.getElementById('page-' + slug) ? slug : '${defaultPageSlug}';
|
289
|
+
navigateTo(targetSlug);
|
182
290
|
});
|
183
291
|
|
184
|
-
|
292
|
+
// Handle direct link clicks
|
293
|
+
navLinks.forEach(link => {
|
185
294
|
link.addEventListener('click', function(e) {
|
186
295
|
e.preventDefault();
|
187
296
|
const slug = this.getAttribute('data-page');
|
188
|
-
navigateTo(slug);
|
297
|
+
if (slug) navigateTo(slug);
|
189
298
|
});
|
190
299
|
});
|
191
300
|
|
192
|
-
|
193
|
-
|
301
|
+
// Initial page load
|
302
|
+
const initialHash = window.location.hash.substring(1);
|
303
|
+
const initialSlug = document.getElementById('page-' + initialHash) ? initialHash : '${defaultPageSlug}';
|
304
|
+
navigateTo(initialSlug);
|
194
305
|
});
|
195
306
|
</script>
|
196
307
|
</body>
|
@@ -198,4 +309,4 @@ export function bundleMultiPageHTML(pages: PageEntry[], logger?: Logger): string
|
|
198
309
|
|
199
310
|
logger?.info(`Multi-page bundle generated. Size: ${Buffer.byteLength(output, 'utf-8')} bytes.`);
|
200
311
|
return output;
|
201
|
-
}
|
312
|
+
}
|