portapack 0.2.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/.eslintrc.json +9 -0
- package/.github/workflows/ci.yml +73 -0
- package/.github/workflows/deploy-pages.yml +56 -0
- package/.prettierrc +9 -0
- package/.releaserc.js +29 -0
- package/CHANGELOG.md +21 -0
- package/README.md +288 -0
- package/commitlint.config.js +36 -0
- package/dist/cli/cli-entry.js +1694 -0
- package/dist/cli/cli-entry.js.map +1 -0
- package/dist/index.d.ts +275 -0
- package/dist/index.js +1405 -0
- package/dist/index.js.map +1 -0
- package/docs/.vitepress/config.ts +89 -0
- package/docs/.vitepress/sidebar-generator.ts +73 -0
- package/docs/cli.md +117 -0
- package/docs/code-of-conduct.md +65 -0
- package/docs/configuration.md +151 -0
- package/docs/contributing.md +107 -0
- package/docs/demo.md +46 -0
- package/docs/deployment.md +132 -0
- package/docs/development.md +168 -0
- package/docs/getting-started.md +106 -0
- package/docs/index.md +40 -0
- package/docs/portapack-transparent.png +0 -0
- package/docs/portapack.jpg +0 -0
- package/docs/troubleshooting.md +107 -0
- package/examples/main.ts +118 -0
- package/examples/sample-project/index.html +12 -0
- package/examples/sample-project/logo.png +1 -0
- package/examples/sample-project/script.js +1 -0
- package/examples/sample-project/styles.css +1 -0
- package/jest.config.ts +124 -0
- package/jest.setup.cjs +211 -0
- package/nodemon.json +11 -0
- package/output.html +1 -0
- package/package.json +161 -0
- package/site-packed.html +1 -0
- package/src/cli/cli-entry.ts +28 -0
- package/src/cli/cli.ts +139 -0
- package/src/cli/options.ts +151 -0
- package/src/core/bundler.ts +201 -0
- package/src/core/extractor.ts +618 -0
- package/src/core/minifier.ts +233 -0
- package/src/core/packer.ts +191 -0
- package/src/core/parser.ts +115 -0
- package/src/core/web-fetcher.ts +292 -0
- package/src/index.ts +262 -0
- package/src/types.ts +163 -0
- package/src/utils/font.ts +41 -0
- package/src/utils/logger.ts +139 -0
- package/src/utils/meta.ts +100 -0
- package/src/utils/mime.ts +90 -0
- package/src/utils/slugify.ts +70 -0
- package/test-output.html +0 -0
- package/tests/__fixtures__/sample-project/index.html +5 -0
- package/tests/unit/cli/cli-entry.test.ts +104 -0
- package/tests/unit/cli/cli.test.ts +230 -0
- package/tests/unit/cli/options.test.ts +316 -0
- package/tests/unit/core/bundler.test.ts +287 -0
- package/tests/unit/core/extractor.test.ts +1129 -0
- package/tests/unit/core/minifier.test.ts +414 -0
- package/tests/unit/core/packer.test.ts +193 -0
- package/tests/unit/core/parser.test.ts +540 -0
- package/tests/unit/core/web-fetcher.test.ts +374 -0
- package/tests/unit/index.test.ts +339 -0
- package/tests/unit/utils/font.test.ts +81 -0
- package/tests/unit/utils/logger.test.ts +275 -0
- package/tests/unit/utils/meta.test.ts +70 -0
- package/tests/unit/utils/mime.test.ts +96 -0
- package/tests/unit/utils/slugify.test.ts +71 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.jest.json +17 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +71 -0
- package/typedoc.json +28 -0
@@ -0,0 +1,151 @@
|
|
1
|
+
/**
|
2
|
+
* @file src/cli/options.ts
|
3
|
+
* @description Centralized CLI argument parser for PortaPack using Commander.
|
4
|
+
* Returns strongly typed options object including the determined LogLevel.
|
5
|
+
*/
|
6
|
+
|
7
|
+
import { Command, Option } from 'commander';
|
8
|
+
// Import LogLevel enum and names type from the central types file
|
9
|
+
// Ensure CLIOptions is imported correctly if defined in types.ts
|
10
|
+
import { LogLevel, type LogLevelName, type CLIOptions } from '../types';
|
11
|
+
|
12
|
+
|
13
|
+
// Define valid choices for the --log-level option
|
14
|
+
const logLevels: LogLevelName[] = ['debug', 'info', 'warn', 'error', 'silent', 'none'];
|
15
|
+
|
16
|
+
/**
|
17
|
+
* Custom parser for the --recursive option value.
|
18
|
+
* Treats flag without value, non-numeric value, or negative value as true.
|
19
|
+
*
|
20
|
+
* @param {string | undefined} val - The value passed to the option.
|
21
|
+
* @returns {boolean | number} True if flag only/invalid number, otherwise the parsed depth.
|
22
|
+
*/
|
23
|
+
function parseRecursiveValue(val: string | undefined): boolean | number {
|
24
|
+
if (val === undefined) return true; // Flag only
|
25
|
+
const parsed = parseInt(val, 10);
|
26
|
+
// Invalid number (NaN) or negative depth treated as simple boolean 'true'
|
27
|
+
return isNaN(parsed) || parsed < 0 ? true : parsed;
|
28
|
+
}
|
29
|
+
|
30
|
+
/**
|
31
|
+
* Parses CLI arguments using Commander and returns a typed CLIOptions object.
|
32
|
+
* Handles mapping --verbose and --log-level flags to the appropriate LogLevel enum value.
|
33
|
+
* Handles mapping --no-minify to individual minification flags.
|
34
|
+
* Ensures flags like --no-embed-assets correctly override their positive counterparts.
|
35
|
+
*
|
36
|
+
* @param {string[]} [argv=process.argv] - Command-line arguments array (e.g., process.argv).
|
37
|
+
* @returns {CLIOptions} Parsed and structured options object.
|
38
|
+
* @throws {Error} Throws errors if Commander encounters parsing/validation issues.
|
39
|
+
*/
|
40
|
+
export function parseOptions(argv: string[] = process.argv): CLIOptions {
|
41
|
+
const program = new Command();
|
42
|
+
|
43
|
+
program
|
44
|
+
.name('portapack')
|
45
|
+
.version('0.0.0') // Version updated dynamically by cli.ts
|
46
|
+
.description('📦 Bundle HTML and its dependencies into a portable file')
|
47
|
+
.argument('[input]', 'Input HTML file or URL')
|
48
|
+
.option('-o, --output <file>', 'Output file path')
|
49
|
+
.option('-m, --minify', 'Enable all minification (HTML, CSS, JS)') // Presence enables default true below
|
50
|
+
.option('--no-minify', 'Disable all minification') // Global disable flag
|
51
|
+
.option('--no-minify-html', 'Disable HTML minification')
|
52
|
+
.option('--no-minify-css', 'Disable CSS minification')
|
53
|
+
.option('--no-minify-js', 'Disable JavaScript minification')
|
54
|
+
.option('-e, --embed-assets', 'Embed assets as data URIs') // Presence enables default true below
|
55
|
+
.option('--no-embed-assets', 'Keep asset links relative/absolute') // Disable flag
|
56
|
+
.option('-r, --recursive [depth]', 'Recursively crawl site (optional depth)', parseRecursiveValue)
|
57
|
+
.option('--max-depth <n>', 'Set max depth for recursive crawl (alias for -r <n>)', parseInt)
|
58
|
+
.option('-b, --base-url <url>', 'Base URL for resolving relative links')
|
59
|
+
.option('-d, --dry-run', 'Run without writing output file')
|
60
|
+
.option('-v, --verbose', 'Enable verbose (debug) logging')
|
61
|
+
.addOption(new Option('--log-level <level>', 'Set logging level')
|
62
|
+
.choices(logLevels));
|
63
|
+
|
64
|
+
// Prevent commander from exiting on error during tests (optional)
|
65
|
+
// program.exitOverride();
|
66
|
+
|
67
|
+
program.parse(argv);
|
68
|
+
|
69
|
+
// Raw options object from Commander's parsing
|
70
|
+
const opts = program.opts<CLIOptions>();
|
71
|
+
// Get the positional argument (input) if provided
|
72
|
+
const inputArg = program.args.length > 0 ? program.args[0] : undefined;
|
73
|
+
|
74
|
+
// --- Determine Effective LogLevel ---
|
75
|
+
let finalLogLevel: LogLevel;
|
76
|
+
const cliLogLevel = opts.logLevel as unknown as LogLevelName | undefined; // Commander stores choice string
|
77
|
+
if (cliLogLevel) {
|
78
|
+
// Map string choice to LogLevel enum value
|
79
|
+
switch (cliLogLevel) {
|
80
|
+
case 'debug': finalLogLevel = LogLevel.DEBUG; break;
|
81
|
+
case 'info': finalLogLevel = LogLevel.INFO; break;
|
82
|
+
case 'warn': finalLogLevel = LogLevel.WARN; break;
|
83
|
+
case 'error': finalLogLevel = LogLevel.ERROR; break;
|
84
|
+
case 'silent': case 'none': finalLogLevel = LogLevel.NONE; break;
|
85
|
+
default: finalLogLevel = LogLevel.INFO; // Fallback, though choices() should prevent this
|
86
|
+
}
|
87
|
+
} else if (opts.verbose) {
|
88
|
+
// --verbose is shorthand for debug level if --log-level not set
|
89
|
+
finalLogLevel = LogLevel.DEBUG;
|
90
|
+
} else {
|
91
|
+
// Default log level
|
92
|
+
finalLogLevel = LogLevel.INFO;
|
93
|
+
}
|
94
|
+
|
95
|
+
// --- Handle Embedding ---
|
96
|
+
// Default is true. --no-embed-assets flag sets opts.embedAssets to false.
|
97
|
+
// Check argv directly to ensure --no- wins regardless of order.
|
98
|
+
let embedAssets = true; // Start with default
|
99
|
+
if (argv.includes('--no-embed-assets')) {
|
100
|
+
embedAssets = false; // Explicit negation flag takes precedence
|
101
|
+
} else if (opts.embedAssets === true) {
|
102
|
+
embedAssets = true; // Positive flag enables it if negation wasn't present
|
103
|
+
}
|
104
|
+
// If neither flag is present, it remains the default 'true'.
|
105
|
+
|
106
|
+
// --- Handle Minification ---
|
107
|
+
// Default to true unless specifically disabled by --no-minify-<type>
|
108
|
+
let minifyHtml = opts.minifyHtml !== false;
|
109
|
+
let minifyCss = opts.minifyCss !== false;
|
110
|
+
let minifyJs = opts.minifyJs !== false;
|
111
|
+
|
112
|
+
// Global --no-minify flag overrides all individual settings
|
113
|
+
// Commander sets opts.minify to false if --no-minify is used.
|
114
|
+
if (opts.minify === false) {
|
115
|
+
minifyHtml = false;
|
116
|
+
minifyCss = false;
|
117
|
+
minifyJs = false;
|
118
|
+
}
|
119
|
+
// Note: Positive flags (-m or individual --minify-<type>) don't need extra handling
|
120
|
+
// as the initial state is true, and negations correctly turn them off.
|
121
|
+
|
122
|
+
// --- Handle Recursive/MaxDepth ---
|
123
|
+
// Start with the value parsed from -r/--recursive
|
124
|
+
let recursiveOpt = opts.recursive;
|
125
|
+
// If --max-depth was provided and is a valid non-negative number, it overrides -r
|
126
|
+
if (opts.maxDepth !== undefined && !isNaN(opts.maxDepth) && opts.maxDepth >= 0) {
|
127
|
+
recursiveOpt = opts.maxDepth;
|
128
|
+
}
|
129
|
+
|
130
|
+
// Return the final structured options object
|
131
|
+
return {
|
132
|
+
// Pass through directly parsed options
|
133
|
+
baseUrl: opts.baseUrl,
|
134
|
+
dryRun: opts.dryRun ?? false, // Ensure boolean, default false
|
135
|
+
output: opts.output,
|
136
|
+
verbose: opts.verbose ?? false, // Ensure boolean, default false
|
137
|
+
|
138
|
+
// Set calculated/processed options
|
139
|
+
input: inputArg,
|
140
|
+
logLevel: finalLogLevel,
|
141
|
+
recursive: recursiveOpt, // Final calculated value for recursion
|
142
|
+
embedAssets: embedAssets, // Final calculated value
|
143
|
+
minifyHtml: minifyHtml, // Final calculated value
|
144
|
+
minifyCss: minifyCss, // Final calculated value
|
145
|
+
minifyJs: minifyJs, // Final calculated value
|
146
|
+
|
147
|
+
// Exclude intermediate commander properties like:
|
148
|
+
// minify, logLevel (string version), maxDepth,
|
149
|
+
// minifyHtml, minifyCss, minifyJs (commander's raw boolean flags)
|
150
|
+
};
|
151
|
+
}
|
@@ -0,0 +1,201 @@
|
|
1
|
+
/**
|
2
|
+
* @file bundler.ts
|
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
|
5
|
+
*/
|
6
|
+
|
7
|
+
import { dirname, resolve } from 'path';
|
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';
|
15
|
+
|
16
|
+
/**
|
17
|
+
* Determines the appropriate base URL for resolving relative assets
|
18
|
+
* based on input HTML file path or URL.
|
19
|
+
*
|
20
|
+
* @param input - The original HTML path or URL.
|
21
|
+
* @param logger - Optional logger instance.
|
22
|
+
* @returns The resolved base URL, ending in a trailing slash.
|
23
|
+
*/
|
24
|
+
function determineBaseUrl(input: string, logger?: Logger): string {
|
25
|
+
try {
|
26
|
+
if (input.startsWith('http://') || input.startsWith('https://')) {
|
27
|
+
const url = new URL(input);
|
28
|
+
url.pathname = url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1);
|
29
|
+
url.search = '';
|
30
|
+
url.hash = '';
|
31
|
+
const baseUrl = url.toString();
|
32
|
+
logger?.debug(`Determined remote base URL: ${baseUrl}`);
|
33
|
+
return baseUrl;
|
34
|
+
} else {
|
35
|
+
const absoluteDir = dirname(resolve(input));
|
36
|
+
const baseUrl = pathToFileURL(absoluteDir + '/').href;
|
37
|
+
logger?.debug(`Determined local base URL: ${baseUrl}`);
|
38
|
+
return baseUrl;
|
39
|
+
}
|
40
|
+
} catch (error: any) {
|
41
|
+
logger?.error(`Failed to determine base URL for "${input}": ${error.message}`);
|
42
|
+
return './';
|
43
|
+
}
|
44
|
+
}
|
45
|
+
|
46
|
+
/**
|
47
|
+
* Creates a self-contained HTML file from a parsed HTML structure and options.
|
48
|
+
*
|
49
|
+
* @param parsedHtml - The parsed HTML document.
|
50
|
+
* @param inputPath - The original input file or URL for base URL calculation.
|
51
|
+
* @param options - Optional bundling options.
|
52
|
+
* @param logger - Optional logger instance.
|
53
|
+
* @returns A fully inlined and bundled HTML string.
|
54
|
+
*/
|
55
|
+
export async function bundleSingleHTML(
|
56
|
+
parsedHtml: ParsedHTML,
|
57
|
+
inputPath: string,
|
58
|
+
options: BundleOptions = {},
|
59
|
+
logger?: Logger
|
60
|
+
): 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
|
+
}
|
80
|
+
|
81
|
+
logger?.debug(`Starting HTML bundling for ${inputPath}`);
|
82
|
+
logger?.debug(`Effective options: ${JSON.stringify(mergedOptions, null, 2)}`);
|
83
|
+
|
84
|
+
const extracted = await extractAssets(parsedHtml, mergedOptions.embedAssets, mergedOptions.baseUrl, logger);
|
85
|
+
const minified = await minifyAssets(extracted, mergedOptions, logger);
|
86
|
+
const result = packHTML(minified, logger);
|
87
|
+
|
88
|
+
logger?.info(`Single HTML bundling complete for: ${inputPath}`);
|
89
|
+
return result;
|
90
|
+
} catch (error: any) {
|
91
|
+
logger?.error(`Error during single HTML bundling: ${error.message}`);
|
92
|
+
throw error;
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
/**
|
97
|
+
* Combines multiple HTML pages into a single HTML file with client-side routing.
|
98
|
+
*
|
99
|
+
* @param pages - An array of PageEntry objects (each with a URL and HTML content).
|
100
|
+
* @param logger - Optional logger for diagnostics.
|
101
|
+
* @returns A complete HTML document as a string.
|
102
|
+
* @throws {Error} If the input is invalid or contains no usable pages.
|
103
|
+
*/
|
104
|
+
export function bundleMultiPageHTML(pages: PageEntry[], logger?: Logger): string {
|
105
|
+
if (!Array.isArray(pages)) {
|
106
|
+
const errorMsg = 'Input pages must be an array of PageEntry objects';
|
107
|
+
logger?.error(errorMsg);
|
108
|
+
throw new Error(errorMsg);
|
109
|
+
}
|
110
|
+
|
111
|
+
logger?.info(`Bundling ${pages.length} pages into a multi-page HTML document.`);
|
112
|
+
|
113
|
+
const validPages = pages.filter(page => {
|
114
|
+
const isValid = page && typeof page === 'object' && typeof page.url === 'string' && typeof page.html === 'string';
|
115
|
+
if (!isValid) logger?.warn('Skipping invalid page entry');
|
116
|
+
return isValid;
|
117
|
+
});
|
118
|
+
|
119
|
+
if (validPages.length === 0) {
|
120
|
+
const errorMsg = 'No valid page entries found in input array';
|
121
|
+
logger?.error(errorMsg);
|
122
|
+
throw new Error(errorMsg);
|
123
|
+
}
|
124
|
+
|
125
|
+
const slugMap = new Map<string, string>();
|
126
|
+
const usedSlugs = new Set<string>();
|
127
|
+
|
128
|
+
for (const page of validPages) {
|
129
|
+
const baseSlug = sanitizeSlug(page.url);
|
130
|
+
let slug = baseSlug;
|
131
|
+
let counter = 1;
|
132
|
+
while (usedSlugs.has(slug)) {
|
133
|
+
slug = `${baseSlug}-${counter++}`;
|
134
|
+
logger?.warn(`Slug collision detected for "${page.url}". Using "${slug}" instead.`);
|
135
|
+
}
|
136
|
+
usedSlugs.add(slug);
|
137
|
+
slugMap.set(page.url, slug);
|
138
|
+
}
|
139
|
+
|
140
|
+
const defaultPageSlug = slugMap.get(validPages[0].url);
|
141
|
+
|
142
|
+
let output = `<!DOCTYPE html>
|
143
|
+
<html lang="en">
|
144
|
+
<head>
|
145
|
+
<meta charset="UTF-8">
|
146
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
147
|
+
<title>Multi-Page Bundle</title>
|
148
|
+
</head>
|
149
|
+
<body>
|
150
|
+
<nav id="main-nav">
|
151
|
+
${validPages.map(p => {
|
152
|
+
const slug = slugMap.get(p.url)!;
|
153
|
+
const label = p.url.split('/').pop()?.split('.')[0] || 'Page';
|
154
|
+
return `<a href="#${slug}" data-page="${slug}">${label}</a>`;
|
155
|
+
}).join('\n')}
|
156
|
+
</nav>
|
157
|
+
<div id="page-container"></div>
|
158
|
+
${validPages.map(p => {
|
159
|
+
const slug = slugMap.get(p.url)!;
|
160
|
+
return `<template id="page-${slug}">${p.html}</template>`;
|
161
|
+
}).join('\n')}
|
162
|
+
<script id="router-script">
|
163
|
+
document.addEventListener('DOMContentLoaded', function() {
|
164
|
+
function navigateTo(slug) {
|
165
|
+
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');
|
173
|
+
});
|
174
|
+
if (window.location.hash.substring(1) !== slug) {
|
175
|
+
history.pushState(null, '', '#' + slug);
|
176
|
+
}
|
177
|
+
}
|
178
|
+
|
179
|
+
window.addEventListener('hashchange', () => {
|
180
|
+
const slug = window.location.hash.substring(1);
|
181
|
+
if (document.getElementById('page-' + slug)) navigateTo(slug);
|
182
|
+
});
|
183
|
+
|
184
|
+
document.querySelectorAll('#main-nav a').forEach(link => {
|
185
|
+
link.addEventListener('click', function(e) {
|
186
|
+
e.preventDefault();
|
187
|
+
const slug = this.getAttribute('data-page');
|
188
|
+
navigateTo(slug);
|
189
|
+
});
|
190
|
+
});
|
191
|
+
|
192
|
+
const initial = window.location.hash.substring(1);
|
193
|
+
navigateTo(document.getElementById('page-' + initial) ? initial : '${defaultPageSlug}');
|
194
|
+
});
|
195
|
+
</script>
|
196
|
+
</body>
|
197
|
+
</html>`;
|
198
|
+
|
199
|
+
logger?.info(`Multi-page bundle generated. Size: ${Buffer.byteLength(output, 'utf-8')} bytes.`);
|
200
|
+
return output;
|
201
|
+
}
|