portapack 0.3.1 → 0.3.3

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