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