portapack 0.2.1 → 0.3.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.
package/src/cli/cli.ts CHANGED
@@ -1,139 +1,150 @@
1
1
  /**
2
2
  * @file cli.ts
3
3
  * @description
4
- * Main CLI runner for PortaPack. Handles parsing CLI args, executing the HTML bundler,
5
- * writing output to disk, logging metadata, and returning structured results.
4
+ * Main CLI runner for PortaPack. Handles argument parsing, calls the bundler via `pack()`,
5
+ * writes output to disk (unless dry-run), logs build stats, and captures structured output.
6
6
  */
7
7
 
8
- import fs from 'fs'; // Use default import if mocking default below
8
+ import fs from 'fs';
9
9
  import path from 'path';
10
- import { fileURLToPath } from 'url';
10
+ // Use standard require for core modules in CJS context if needed
11
+ // const path = require('path');
12
+ // const fs = require('fs');
11
13
 
12
- import { parseOptions } from './options.js';
13
- import { generatePortableHTML, generateRecursivePortableHTML } from '../index';
14
- import type { CLIResult } from '../types';
15
-
16
- import { LogLevel } from '../types';
14
+ import { parseOptions } from './options';
15
+ import { pack } from '../index';
16
+ // Import CLIOptions correctly
17
+ import type { CLIResult, BundleOptions, BundleMetadata, CLIOptions } from '../types';
17
18
 
18
19
  /**
19
- * Dynamically loads package.json metadata.
20
+ * Dynamically loads version info from package.json using CommonJS compatible method.
21
+ *
22
+ * @returns {Record<string, any>} Parsed package.json or fallback
20
23
  */
21
24
  function getPackageJson(): Record<string, any> {
22
- try {
23
- const __filename = fileURLToPath(import.meta.url);
24
- const __dirname = path.dirname(__filename);
25
- const pkgPath = path.resolve(__dirname, '../../package.json');
26
-
27
- // Use fs directly, assuming mock works or it's okay in non-test env
28
- if (fs.existsSync(pkgPath)) {
29
- return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
30
- }
31
- } catch (_) {
32
- // Ignore and fallback
33
- }
34
- return { version: '0.1.0' }; // Default fallback version
25
+ try {
26
+ // FIX: Use require.resolve which works in CommonJS to find the package path
27
+ // It resolves relative to the location of this file or the node_modules structure
28
+ // Assumes 'portapack' is the package name defined in package.json
29
+ // We need the package.json itself, so resolve 'portapack/package.json'
30
+ // Use __dirname if available in CJS context, otherwise try relative from cwd as fallback
31
+ const searchPath = typeof __dirname !== 'undefined' ? path.join(__dirname, '..', '..') : process.cwd();
32
+ const pkgJsonPath = require.resolve('portapack/package.json', { paths: [searchPath] });
33
+ return require(pkgJsonPath); // Use require directly to load JSON
34
+ } catch (err) {
35
+ console.error("Warning: Could not dynamically load package.json for version.", err); // Log error for debugging
36
+ return { version: '0.0.0-unknown' };
37
+ }
35
38
  }
36
39
 
37
40
  /**
38
- * Entry function for running the CLI.
41
+ * Entrypoint for CLI execution. Parses args, runs bundler, logs output and errors.
42
+ *
43
+ * @param {string[]} [argv=process.argv] - Command-line arguments (default: system args)
44
+ * @returns {Promise<CLIResult>} - Structured result containing output, error, and exit code
39
45
  */
40
46
  export async function runCli(argv: string[] = process.argv): Promise<CLIResult> {
41
- let stdout = '';
42
- let stderr = '';
43
- let exitCode = 0;
44
-
45
- // Capture console output for result object
46
- const originalLog = console.log;
47
- const originalErr = console.error;
48
- const originalWarn = console.warn;
49
- console.log = (...args) => { stdout += args.join(' ') + '\n'; };
50
- console.error = (...args) => { stderr += args.join(' ') + '\n'; };
51
- console.warn = (...args) => { stderr += args.join(' ') + '\n'; }; // Capture warnings in stderr too
52
-
53
- let opts: ReturnType<typeof parseOptions> | undefined;
54
- try {
55
- opts = parseOptions(argv);
56
- const version = getPackageJson().version || '0.1.0';
57
-
58
- if (opts.verbose) {
59
- console.log(`📦 PortaPack v${version}`);
60
- }
61
-
62
- if (!opts.input) {
63
- console.error('❌ Missing input file or URL');
64
- // Restore console before returning
65
- console.log = originalLog; console.error = originalErr; console.warn = originalWarn;
66
- return { stdout, stderr, exitCode: 1 };
67
- }
68
-
69
- // Determine output path using nullish coalescing
70
- const outputPath = opts.output ?? `${path.basename(opts.input).split('.')[0] || 'output'}.packed.html`;
71
-
72
- if (opts.verbose) {
73
- console.log(`📥 Input: ${opts.input}`);
74
- console.log(`📤 Output: ${outputPath}`);
75
- // Log other effective options if verbose
76
- console.log(` Recursive: ${opts.recursive ?? false}`);
77
- console.log(` Embed Assets: ${opts.embedAssets}`);
78
- console.log(` Minify HTML: ${opts.minifyHtml}`);
79
- console.log(` Minify CSS: ${opts.minifyCss}`);
80
- console.log(` Minify JS: ${opts.minifyJs}`);
81
- console.log(` Log Level: ${LogLevel[opts.logLevel ?? LogLevel.INFO]}`);
82
- }
83
-
84
- if (opts.dryRun) {
85
- console.log('💡 Dry run mode — no output will be written');
86
- // Restore console before returning
87
- console.log = originalLog; console.error = originalErr; console.warn = originalWarn;
88
- return { stdout, stderr, exitCode: 0 };
89
- }
90
-
91
- // --- FIX: Pass 'opts' object to generate functions ---
92
- const result = opts.recursive
93
- // Convert boolean recursive flag to depth 1 if needed, otherwise use number
94
- ? await generateRecursivePortableHTML(opts.input, typeof opts.recursive === 'boolean' ? 1 : opts.recursive, opts)
95
- : await generatePortableHTML(opts.input, opts);
96
- // ----------------------------------------------------
97
-
98
- // Use fs directly - ensure mock is working in tests
99
- fs.writeFileSync(outputPath, result.html, 'utf-8');
100
-
101
- const meta = result.metadata;
102
- console.log(`✅ Packed: ${meta.input} → ${outputPath}`);
103
- console.log(`📦 Size: ${(meta.outputSize / 1024).toFixed(2)} KB`);
104
- console.log(`⏱️ Time: ${meta.buildTimeMs} ms`); // Use alternative emoji
105
- console.log(`🖼️ Assets: ${meta.assetCount}`); // Add asset count log
106
-
107
- if (meta.pagesBundled && meta.pagesBundled > 0) { // Check > 0 for clarity
108
- console.log(`🧩 Pages: ${meta.pagesBundled}`);
109
- }
110
-
111
- if (meta.errors && meta.errors.length > 0) {
112
- console.warn(`\n⚠️ ${meta.errors.length} warning(s):`); // Add newline for separation
113
- for (const err of meta.errors) {
114
- console.warn(` - ${err}`);
115
- }
116
- }
117
- } catch (err: any) {
118
- console.error(`\n💥 Error: ${err?.message || 'Unknown failure'}`); // Add newline
119
- if (err?.stack && opts?.verbose) { // Show stack only if verbose
120
- console.error(err.stack);
121
- }
122
- exitCode = 1;
123
- } finally {
124
- // Restore original console methods
125
- console.log = originalLog;
126
- console.error = originalErr;
127
- console.warn = originalWarn;
47
+ let stdout = '';
48
+ let stderr = '';
49
+ let exitCode = 0;
50
+
51
+ // Capture console output
52
+ const originalLog = console.log;
53
+ const originalErr = console.error;
54
+ const originalWarn = console.warn;
55
+
56
+ const restoreConsole = () => {
57
+ console.log = originalLog;
58
+ console.error = originalErr;
59
+ console.warn = originalWarn;
60
+ };
61
+
62
+ console.log = (...args) => { stdout += args.join(' ') + '\n'; };
63
+ console.error = (...args) => { stderr += args.join(' ') + '\n'; };
64
+ console.warn = (...args) => { stderr += args.join(' ') + '\n'; };
65
+
66
+ // FIX: Use the correct type CLIOptions which includes 'input'
67
+ let cliOptions: CLIOptions | undefined;
68
+ try {
69
+ // Get the fully parsed options object which includes 'input'
70
+ cliOptions = parseOptions(argv);
71
+ const version = getPackageJson().version || '0.0.0';
72
+
73
+ if (cliOptions.verbose) {
74
+ console.log(`📦 PortaPack v${version}`);
128
75
  }
129
76
 
130
- return { stdout, stderr, exitCode };
131
- }
77
+ // Check for the input property on the correct object
78
+ if (!cliOptions.input) {
79
+ console.error('❌ Missing input file or URL');
80
+ restoreConsole();
81
+ return { stdout, stderr, exitCode: 1 };
82
+ }
83
+
84
+ // Use path.basename and handle potential extension removal carefully
85
+ const inputBasename = path.basename(cliOptions.input);
86
+ const outputDefaultBase = inputBasename.includes('.') ? inputBasename.substring(0, inputBasename.lastIndexOf('.')) : inputBasename;
87
+ // Use the parsed output option or generate default
88
+ const outputPath = cliOptions.output ?? `${outputDefaultBase || 'output'}.packed.html`;
89
+
90
+ if (cliOptions.verbose) {
91
+ console.log(`📥 Input: ${cliOptions.input}`); // Access input correctly
92
+ console.log(`📤 Output: ${outputPath}`);
93
+ // Display other resolved options
94
+ console.log(` Recursive: ${cliOptions.recursive ?? false}`);
95
+ console.log(` Embed Assets: ${cliOptions.embedAssets}`);
96
+ console.log(` Minify HTML: ${cliOptions.minifyHtml}`);
97
+ console.log(` Minify CSS: ${cliOptions.minifyCss}`);
98
+ console.log(` Minify JS: ${cliOptions.minifyJs}`);
99
+ console.log(` Log Level: ${cliOptions.logLevel}`);
100
+ }
101
+
102
+ if (cliOptions.dryRun) {
103
+ console.log('💡 Dry run mode — no output will be written');
104
+ restoreConsole();
105
+ return { stdout, stderr, exitCode: 0 };
106
+ }
107
+
108
+ // FIX: Call pack with input as the first argument, and the rest of the options as the second.
109
+ // The cliOptions object should be compatible with PackOptions expected by pack.
110
+ const result = await pack(cliOptions.input, cliOptions);
111
+
112
+ // Use standard fs sync version as used before
113
+ fs.writeFileSync(outputPath, result.html, 'utf-8');
132
114
 
133
- // Optional: Define main export if this file is intended to be run directly
134
- export const main = runCli;
115
+ const meta = result.metadata;
116
+ // Log results to captured stdout
117
+ console.log(`✅ Packed: ${meta.input} → ${outputPath}`); // meta.input should be correct from pack's result
118
+ console.log(`📦 Size: ${(meta.outputSize / 1024).toFixed(2)} KB`);
119
+ console.log(`⏱️ Time: ${meta.buildTimeMs} ms`);
120
+ console.log(`🖼️ Assets: ${meta.assetCount}`);
135
121
 
136
- // Example direct execution (usually handled by bin entry in package.json)
137
- // if (require.main === module) {
138
- // runCli();
139
- // }
122
+ if (meta.pagesBundled && meta.pagesBundled > 0) {
123
+ console.log(`🧩 Pages: ${meta.pagesBundled}`);
124
+ }
125
+
126
+ if (meta.errors?.length) {
127
+ console.warn(`\n⚠️ ${meta.errors.length} warning(s):`);
128
+ for (const err of meta.errors) {
129
+ console.warn(` - ${err}`);
130
+ }
131
+ }
132
+
133
+ } catch (err: any) {
134
+ console.error(`\n💥 Error: ${err?.message || 'Unknown failure'}`);
135
+ // Check verbose flag on the correct variable
136
+ if (err?.stack && cliOptions?.verbose) {
137
+ console.error(err.stack);
138
+ }
139
+ exitCode = 1;
140
+ } finally {
141
+ restoreConsole();
142
+ }
143
+
144
+ return { stdout, stderr, exitCode };
145
+ }
146
+
147
+ /**
148
+ * Default exportable main runner for CLI invocation.
149
+ */
150
+ export const main = runCli;
@@ -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
+ }