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.
@@ -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.js';
10
- import { minifyAssets } from './minifier.js';
11
- import { packHTML } from './packer.js';
12
- import { Logger } from '../utils/logger.js';
13
- import { ParsedHTML, BundleOptions, PageEntry } from '../types.js';
14
- import { sanitizeSlug, slugify } from '../utils/slugify.js';
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
- const baseUrl = pathToFileURL(absoluteDir + '/').href;
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(`Failed to determine base URL for "${input}": ${error.message}`);
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 inputPath - The original input file or URL for base URL calculation.
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
- inputPath: string,
63
+ inputPathOrUrl: string, // Renamed parameter for clarity
58
64
  options: BundleOptions = {},
59
65
  logger?: Logger
60
66
  ): Promise<string> {
61
- try {
62
- const defaultOptions: Required<BundleOptions> = {
63
- embedAssets: true,
64
- minifyHtml: true,
65
- minifyJs: true,
66
- minifyCss: true,
67
- baseUrl: '',
68
- verbose: false,
69
- dryRun: false,
70
- recursive: false,
71
- output: '',
72
- logLevel: logger?.level ?? 3,
73
- };
74
-
75
- const mergedOptions = { ...defaultOptions, ...options };
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
- logger?.debug(`Starting HTML bundling for ${inputPath}`);
82
- logger?.debug(`Effective options: ${JSON.stringify(mergedOptions, null, 2)}`);
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: ${inputPath}`);
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
- if (!isValid) logger?.warn('Skipping invalid page entry');
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
- const baseSlug = sanitizeSlug(page.url);
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 counter = 1;
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
- slug = `${baseSlug}-${counter++}`;
134
- logger?.warn(`Slug collision detected for "${page.url}". Using "${slug}" instead.`);
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
- const defaultPageSlug = slugMap.get(validPages[0].url);
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 = p.url.split('/').pop()?.split('.')[0] || 'Page';
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
- const container = document.getElementById('page-container');
167
- if (!template || !container) return;
168
- container.innerHTML = '';
169
- container.appendChild(template.content.cloneNode(true));
170
- document.querySelectorAll('#main-nav a').forEach(link => {
171
- if (link.getAttribute('data-page') === slug) link.classList.add('active');
172
- else link.classList.remove('active');
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
- history.pushState(null, '', '#' + slug);
275
+ // Use pushState for cleaner history
276
+ history.pushState({ slug: slug }, '', '#' + slug);
176
277
  }
177
278
  }
178
279
 
179
- window.addEventListener('hashchange', () => {
180
- const slug = window.location.hash.substring(1);
181
- if (document.getElementById('page-' + slug)) navigateTo(slug);
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
- document.querySelectorAll('#main-nav a').forEach(link => {
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
- const initial = window.location.hash.substring(1);
193
- navigateTo(document.getElementById('page-' + initial) ? initial : '${defaultPageSlug}');
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
+ }