portapack 0.3.0 → 0.3.2

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/.github/workflows/ci.yml +5 -4
  3. package/.releaserc.js +25 -27
  4. package/CHANGELOG.md +12 -19
  5. package/LICENSE.md +21 -0
  6. package/README.md +34 -36
  7. package/commitlint.config.js +30 -34
  8. package/dist/cli/cli-entry.cjs +199 -135
  9. package/dist/cli/cli-entry.cjs.map +1 -1
  10. package/dist/index.d.ts +0 -3
  11. package/dist/index.js +194 -134
  12. package/dist/index.js.map +1 -1
  13. package/docs/.vitepress/config.ts +36 -34
  14. package/docs/.vitepress/sidebar-generator.ts +89 -38
  15. package/docs/cli.md +29 -82
  16. package/docs/code-of-conduct.md +7 -1
  17. package/docs/configuration.md +103 -117
  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 +76 -45
  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/site.webmanifest +1 -0
  30. package/docs/troubleshooting.md +12 -1
  31. package/examples/main.ts +7 -10
  32. package/examples/sample-project/script.js +1 -1
  33. package/jest.config.ts +8 -13
  34. package/nodemon.json +5 -10
  35. package/package.json +2 -5
  36. package/src/cli/cli-entry.ts +2 -2
  37. package/src/cli/cli.ts +21 -16
  38. package/src/cli/options.ts +127 -113
  39. package/src/core/bundler.ts +254 -221
  40. package/src/core/extractor.ts +639 -520
  41. package/src/core/minifier.ts +173 -162
  42. package/src/core/packer.ts +141 -137
  43. package/src/core/parser.ts +74 -73
  44. package/src/core/web-fetcher.ts +270 -258
  45. package/src/index.ts +18 -17
  46. package/src/types.ts +9 -11
  47. package/src/utils/font.ts +12 -6
  48. package/src/utils/logger.ts +110 -105
  49. package/src/utils/meta.ts +75 -76
  50. package/src/utils/mime.ts +50 -50
  51. package/src/utils/slugify.ts +33 -34
  52. package/tests/unit/cli/cli-entry.test.ts +72 -70
  53. package/tests/unit/cli/cli.test.ts +314 -278
  54. package/tests/unit/cli/options.test.ts +294 -301
  55. package/tests/unit/core/bundler.test.ts +426 -329
  56. package/tests/unit/core/extractor.test.ts +828 -380
  57. package/tests/unit/core/minifier.test.ts +374 -274
  58. package/tests/unit/core/packer.test.ts +298 -264
  59. package/tests/unit/core/parser.test.ts +538 -150
  60. package/tests/unit/core/web-fetcher.test.ts +389 -359
  61. package/tests/unit/index.test.ts +238 -197
  62. package/tests/unit/utils/font.test.ts +26 -21
  63. package/tests/unit/utils/logger.test.ts +267 -260
  64. package/tests/unit/utils/meta.test.ts +29 -28
  65. package/tests/unit/utils/mime.test.ts +73 -74
  66. package/tests/unit/utils/slugify.test.ts +14 -12
  67. package/tsconfig.build.json +9 -10
  68. package/tsconfig.jest.json +2 -1
  69. package/tsconfig.json +2 -2
  70. package/tsup.config.ts +8 -8
  71. package/typedoc.json +5 -9
  72. package/docs/demo.md +0 -46
  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,124 @@ 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}".`);
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".`);
190
193
  }
191
- // --- END REVISED SLUG LOGIC ---
192
-
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
+ // --- END REVISED SLUG LOGIC ---
193
219
 
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;
204
- }
205
- usedSlugs.add(slug);
206
- slugMap.set(page.url, slug);
220
+ // --- Collision Handling ---
221
+ let slug = baseSlug;
222
+ let collisionCounter = 1;
223
+ // Keep track of the original baseSlug for logging purposes in case of collision
224
+ const originalBaseSlugForLog = baseSlug;
225
+ while (usedSlugs.has(slug)) {
226
+ const newSlug = `${originalBaseSlugForLog}-${collisionCounter++}`;
227
+ // Log with original intended base slug for clarity
228
+ logger?.warn(
229
+ `Slug collision detected for "${page.url}" (intended slug: '${originalBaseSlugForLog}'). Using "${newSlug}" instead.`
230
+ );
231
+ slug = newSlug;
232
+ }
233
+ usedSlugs.add(slug);
234
+ slugMap.set(page.url, slug);
207
235
 
208
- // Track the first valid slug for default navigation
209
- if (firstValidSlug === undefined) { // Use triple equals for check
210
- firstValidSlug = slug;
211
- }
236
+ // Track the first valid slug for default navigation
237
+ if (firstValidSlug === undefined) {
238
+ // Use triple equals for check
239
+ firstValidSlug = slug;
212
240
  }
241
+ }
213
242
 
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');
243
+ // Determine the default page slug - prefer 'index' if present, otherwise use the first page's slug
244
+ // Use 'page' as ultimate fallback if firstValidSlug is somehow still undefined (e.g., only one page failed slug generation)
245
+ const defaultPageSlug = usedSlugs.has('index') ? 'index' : firstValidSlug || 'page';
217
246
 
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>
247
+ // Generate HTML structure
248
+ // (Ensure template IDs use `page-${slug}`)
249
+ // (Ensure nav links use `href="#${slug}"` and `data-page="${slug}"`)
250
+ // (Ensure router script uses `${defaultPageSlug}` correctly)
251
+ const output = `
252
+ <!DOCTYPE html>
223
253
  <html lang="en">
224
254
  <head>
225
255
  <meta charset="UTF-8">
@@ -235,78 +265,81 @@ export function bundleMultiPageHTML(pages: PageEntry[], logger?: Logger): string
235
265
  </style>
236
266
  </head>
237
267
  <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');
268
+ <nav id="main-nav">
269
+ ${validPages
270
+ .map(p => {
271
+ const slug = slugMap.get(p.url)!; // Slug is guaranteed to exist here
272
+ const label = slug; // Use slug as label for simplicity
273
+ return `<a href="#${slug}" data-page="${slug}">${label}</a>`;
274
+ })
275
+ .join('\n ')}
276
+ </nav>
277
+ <div id="page-container"></div>
278
+ ${validPages
279
+ .map(p => {
280
+ const slug = slugMap.get(p.url)!;
281
+ // Basic sanitization/escaping might be needed for p.html if needed
282
+ return `<template id="page-${slug}">${p.html}</template>`;
283
+ })
284
+ .join('\n ')}
285
+ <script id="router-script">
286
+ document.addEventListener('DOMContentLoaded', function() {
287
+ const pageContainer = document.getElementById('page-container');
288
+ const navLinks = document.querySelectorAll('#main-nav a');
255
289
 
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));
290
+ function navigateTo(slug) {
291
+ const template = document.getElementById('page-' + slug);
292
+ if (!template || !pageContainer) {
293
+ console.warn('Navigation failed: Template or container not found for slug:', slug);
294
+ // Maybe try navigating to default page? Or just clear container?
295
+ if (pageContainer) pageContainer.innerHTML = '<p>Page not found.</p>';
296
+ return;
297
+ }
298
+ // Clear previous content and append new content
299
+ pageContainer.innerHTML = ''; // Clear reliably
300
+ pageContainer.appendChild(template.content.cloneNode(true));
267
301
 
268
- // Update active link styling
269
- navLinks.forEach(link => {
270
- link.classList.toggle('active', link.getAttribute('data-page') === slug);
271
- });
302
+ // Update active link styling
303
+ navLinks.forEach(link => {
304
+ link.classList.toggle('active', link.getAttribute('data-page') === slug);
305
+ });
272
306
 
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
- }
307
+ // Update URL hash without triggering hashchange if already correct
308
+ if (window.location.hash.substring(1) !== slug) {
309
+ // Use pushState for cleaner history
310
+ history.pushState({ slug: slug }, '', '#' + slug);
278
311
  }
312
+ }
279
313
 
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
- });
314
+ // Handle back/forward navigation
315
+ window.addEventListener('popstate', (event) => {
316
+ let slug = window.location.hash.substring(1);
317
+ // If popstate event has state use it, otherwise fallback to hash or default
318
+ if (event && event.state && event.state.slug) { // Check event exists
319
+ slug = event.state.slug;
320
+ }
321
+ // Ensure the target page exists before navigating, fallback to default slug
322
+ const targetSlug = document.getElementById('page-' + slug) ? slug : '${defaultPageSlug}';
323
+ navigateTo(targetSlug);
324
+ });
291
325
 
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
- });
326
+ // Handle direct link clicks
327
+ navLinks.forEach(link => {
328
+ link.addEventListener('click', function(e) {
329
+ e.preventDefault();
330
+ const slug = this.getAttribute('data-page');
331
+ if (slug) navigateTo(slug);
299
332
  });
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
333
  });
306
- </script>
334
+
335
+ // Initial page load
336
+ const initialHash = window.location.hash.substring(1);
337
+ const initialSlug = document.getElementById('page-' + initialHash) ? initialHash : '${defaultPageSlug}';
338
+ navigateTo(initialSlug);
339
+ });
340
+ </script>
307
341
  </body>
308
342
  </html>`;
309
-
310
- logger?.info(`Multi-page bundle generated. Size: ${Buffer.byteLength(output, 'utf-8')} bytes.`);
311
- return output;
312
- }
343
+ logger?.info(`Multi-page bundle generated. Size: ${Buffer.byteLength(output, 'utf-8')} bytes.`);
344
+ return output;
345
+ }