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.
Files changed (76) hide show
  1. package/.eslintrc.json +9 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/.github/workflows/deploy-pages.yml +56 -0
  4. package/.prettierrc +9 -0
  5. package/.releaserc.js +29 -0
  6. package/CHANGELOG.md +21 -0
  7. package/README.md +288 -0
  8. package/commitlint.config.js +36 -0
  9. package/dist/cli/cli-entry.js +1694 -0
  10. package/dist/cli/cli-entry.js.map +1 -0
  11. package/dist/index.d.ts +275 -0
  12. package/dist/index.js +1405 -0
  13. package/dist/index.js.map +1 -0
  14. package/docs/.vitepress/config.ts +89 -0
  15. package/docs/.vitepress/sidebar-generator.ts +73 -0
  16. package/docs/cli.md +117 -0
  17. package/docs/code-of-conduct.md +65 -0
  18. package/docs/configuration.md +151 -0
  19. package/docs/contributing.md +107 -0
  20. package/docs/demo.md +46 -0
  21. package/docs/deployment.md +132 -0
  22. package/docs/development.md +168 -0
  23. package/docs/getting-started.md +106 -0
  24. package/docs/index.md +40 -0
  25. package/docs/portapack-transparent.png +0 -0
  26. package/docs/portapack.jpg +0 -0
  27. package/docs/troubleshooting.md +107 -0
  28. package/examples/main.ts +118 -0
  29. package/examples/sample-project/index.html +12 -0
  30. package/examples/sample-project/logo.png +1 -0
  31. package/examples/sample-project/script.js +1 -0
  32. package/examples/sample-project/styles.css +1 -0
  33. package/jest.config.ts +124 -0
  34. package/jest.setup.cjs +211 -0
  35. package/nodemon.json +11 -0
  36. package/output.html +1 -0
  37. package/package.json +161 -0
  38. package/site-packed.html +1 -0
  39. package/src/cli/cli-entry.ts +28 -0
  40. package/src/cli/cli.ts +139 -0
  41. package/src/cli/options.ts +151 -0
  42. package/src/core/bundler.ts +201 -0
  43. package/src/core/extractor.ts +618 -0
  44. package/src/core/minifier.ts +233 -0
  45. package/src/core/packer.ts +191 -0
  46. package/src/core/parser.ts +115 -0
  47. package/src/core/web-fetcher.ts +292 -0
  48. package/src/index.ts +262 -0
  49. package/src/types.ts +163 -0
  50. package/src/utils/font.ts +41 -0
  51. package/src/utils/logger.ts +139 -0
  52. package/src/utils/meta.ts +100 -0
  53. package/src/utils/mime.ts +90 -0
  54. package/src/utils/slugify.ts +70 -0
  55. package/test-output.html +0 -0
  56. package/tests/__fixtures__/sample-project/index.html +5 -0
  57. package/tests/unit/cli/cli-entry.test.ts +104 -0
  58. package/tests/unit/cli/cli.test.ts +230 -0
  59. package/tests/unit/cli/options.test.ts +316 -0
  60. package/tests/unit/core/bundler.test.ts +287 -0
  61. package/tests/unit/core/extractor.test.ts +1129 -0
  62. package/tests/unit/core/minifier.test.ts +414 -0
  63. package/tests/unit/core/packer.test.ts +193 -0
  64. package/tests/unit/core/parser.test.ts +540 -0
  65. package/tests/unit/core/web-fetcher.test.ts +374 -0
  66. package/tests/unit/index.test.ts +339 -0
  67. package/tests/unit/utils/font.test.ts +81 -0
  68. package/tests/unit/utils/logger.test.ts +275 -0
  69. package/tests/unit/utils/meta.test.ts +70 -0
  70. package/tests/unit/utils/mime.test.ts +96 -0
  71. package/tests/unit/utils/slugify.test.ts +71 -0
  72. package/tsconfig.build.json +11 -0
  73. package/tsconfig.jest.json +17 -0
  74. package/tsconfig.json +20 -0
  75. package/tsup.config.ts +71 -0
  76. package/typedoc.json +28 -0
package/jest.setup.cjs ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @file jest.setup.cjs
3
+ * @description Jest global setup script executed before test suites run (per file).
4
+ * Responsible for creating temporary directories for test outputs and fixtures,
5
+ * setting up mock data (like the sample project), and cleaning up afterwards.
6
+ * Uses CommonJS syntax as Jest setup files often run in a CJS context.
7
+ */
8
+
9
+ // Node.js core modules required for setup
10
+ const fs = require('fs/promises'); // Using promises API for async operations
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+ const os = require('os');
14
+
15
+ // Generate a unique ID for each test execution session.
16
+ // This helps isolate test runs if running concurrently or prevents conflicts
17
+ // if cleanup fails on a previous run.
18
+ const TEST_RUN_ID = crypto.randomBytes(4).toString('hex');
19
+
20
+ // Helper function for resolving paths relative to this setup file's directory
21
+ // (usually the project root or a specific test config directory)
22
+ const resolve = (...args) => path.resolve(__dirname, ...args);
23
+
24
+ /**
25
+ * Utility function to ensure a directory exists.
26
+ * Creates the directory recursively if it doesn't exist.
27
+ * Ignores 'EEXIST' error if the directory is already present.
28
+ * @param {string} dir - The absolute path of the directory to ensure.
29
+ * @returns {Promise<void>}
30
+ * @throws {Error} Throws errors other than 'EEXIST' during directory creation.
31
+ */
32
+ const ensureDir = async (dir) => {
33
+ try {
34
+ await fs.mkdir(dir, { recursive: true });
35
+ } catch (e) {
36
+ // If the error is simply that the directory already exists, ignore it.
37
+ if (e.code !== 'EEXIST') {
38
+ console.error(`Failed to ensure directory ${dir}:`, e); // Log unexpected errors
39
+ throw e; // Re-throw other errors
40
+ }
41
+ }
42
+ };
43
+
44
+ // Define the base temporary directory using the OS's temp location and the unique run ID.
45
+ // Using os.tmpdir() is generally safer regarding permissions and OS conventions.
46
+ const tempDir = path.join(os.tmpdir(), 'portapack-tests', TEST_RUN_ID);
47
+
48
+ /**
49
+ * Object containing base paths for various test-related directories.
50
+ * These are constructed relative to the main temporary directory.
51
+ */
52
+ const baseDirs = {
53
+ root: resolve(), // Project root (where jest.config / setup file likely is)
54
+ output: path.join(tempDir, 'test-output'), // General output for test artifacts
55
+ fixtures: path.join(tempDir, '__fixtures__'), // Base for fixture data (if needed)
56
+ fixturesOutput: path.join(tempDir, '__fixtures__/output'), // Output specifically related to fixtures
57
+ sampleProject: path.join(tempDir, 'sample-project') // Directory for the mock HTML project
58
+ };
59
+
60
+ /**
61
+ * Jest's global beforeAll hook. Runs once before any tests in a suite file execute.
62
+ * Sets up the necessary directory structure and global variables/mocks for tests.
63
+ */
64
+ beforeAll(async () => {
65
+ console.log(`Setting up test environment in: ${tempDir}`); // Log setup start
66
+
67
+ // Create essential output/fixture directories early.
68
+ // Using Promise.all here is likely safe as they are independent paths.
69
+ await Promise.all([
70
+ ensureDir(baseDirs.output),
71
+ ensureDir(baseDirs.fixturesOutput)
72
+ ]);
73
+
74
+ // Define paths specific to this test run using the TEST_RUN_ID for further isolation.
75
+ const runDirs = {
76
+ uniqueOutput: path.join(baseDirs.output, TEST_RUN_ID),
77
+ uniqueFixturesOutput: path.join(baseDirs.fixturesOutput, TEST_RUN_ID)
78
+ };
79
+
80
+ // Create the run-specific directories.
81
+ await Promise.all([
82
+ fs.mkdir(runDirs.uniqueOutput, { recursive: true }),
83
+ fs.mkdir(runDirs.uniqueFixturesOutput, { recursive: true })
84
+ ]);
85
+
86
+ // --- Set up global variables accessible within tests ---
87
+ // This provides a consistent way for tests to access temporary paths.
88
+ global.__TEST_DIRECTORIES__ = {
89
+ ...baseDirs,
90
+ ...runDirs
91
+ };
92
+
93
+ // Helper function for tests to get a path within the unique test output directory.
94
+ global.getTestFilePath = (relPath) =>
95
+ path.join(global.__TEST_DIRECTORIES__.uniqueOutput, relPath);
96
+
97
+ // Helper function for tests to get a path within the unique fixture output directory.
98
+ global.getTestFixturePath = (relPath) =>
99
+ path.join(global.__TEST_DIRECTORIES__.uniqueFixturesOutput, relPath);
100
+
101
+ // --- Create specific mock files needed by certain tests ---
102
+ // Example: Creating virtual font files for font processing tests.
103
+ await ensureDir(path.dirname(path.join(tempDir, 'font.woff2'))); // Ensure parent dir exists
104
+ await ensureDir(path.dirname(path.join(tempDir, 'font.ttf')));
105
+ // No need to ensureDir for 'missing.ttf' as we only need the path, not the file
106
+
107
+ // Write mock content to the font files.
108
+ await fs.writeFile(path.join(tempDir, 'font.woff2'), 'mock woff2 data');
109
+ await fs.writeFile(path.join(tempDir, 'font.ttf'), 'mock ttf data');
110
+
111
+ // Set up a global variable pointing to the temp directory for potential use in mocks.
112
+ global.__MOCK_FILE_PATH__ = tempDir;
113
+
114
+ // --- Create the stub/mock sample HTML project ---
115
+ // **FIX APPLIED HERE:** Ensure directory exists *before* writing files into it
116
+ // to prevent ENOENT race conditions.
117
+
118
+ console.log(`Ensuring sample project directory exists: ${baseDirs.sampleProject}`);
119
+ // 1. Ensure the sample project directory exists FIRST
120
+ await ensureDir(baseDirs.sampleProject);
121
+
122
+ // 2. THEN write all the files into it using Promise.all for concurrency (safe now).
123
+ try {
124
+ console.log(`Writing sample project files to ${baseDirs.sampleProject}...`);
125
+ await Promise.all([
126
+ fs.writeFile(
127
+ path.join(baseDirs.sampleProject, 'index.html'),
128
+ `<!DOCTYPE html><html><head><link rel="stylesheet" href="styles.css"></head><body><img src="logo.png"/><script src="script.js"></script></body></html>`
129
+ ),
130
+ fs.writeFile(path.join(baseDirs.sampleProject, 'styles.css'), 'body { margin: 0; }'),
131
+ fs.writeFile(path.join(baseDirs.sampleProject, 'script.js'), `console.log('hello');`),
132
+ fs.writeFile(path.join(baseDirs.sampleProject, 'logo.png'), 'fake image data') // Using string for simplicity
133
+ ]);
134
+ console.log(`Successfully wrote sample project files.`);
135
+ } catch (writeError) {
136
+ console.error(`Failed to write sample project files to ${baseDirs.sampleProject}:`, writeError);
137
+ // Depending on setup needs, you might want to stop the tests here.
138
+ throw writeError; // Re-throw to potentially fail the setup.
139
+ }
140
+ // --- End Fix ---
141
+
142
+ // --- Mock console methods ---
143
+ // This helps keep test output clean and allows assertions on console messages.
144
+ const originalLog = console.log;
145
+ const originalWarn = console.warn;
146
+ const originalError = console.error;
147
+
148
+ // Replace global console methods with Jest mocks.
149
+ global.console.log = jest.fn((...args) => {
150
+ // Only log to actual console if DEBUG environment variable is set.
151
+ if (process.env.DEBUG) {
152
+ originalLog(...args);
153
+ }
154
+ });
155
+
156
+ global.console.warn = jest.fn((...args) => {
157
+ // Optionally filter specific warnings you expect and don't want cluttering output.
158
+ const msg = args.join(' ');
159
+ if (
160
+ msg.includes('Could not fetch asset') ||
161
+ msg.includes('Error minifying')
162
+ // Add other expected warning patterns here if needed
163
+ ) {
164
+ return; // Suppress specific known warnings
165
+ }
166
+ // Log other warnings, potentially only in debug mode.
167
+ if (process.env.DEBUG) {
168
+ originalWarn(...args);
169
+ }
170
+ });
171
+
172
+ global.console.error = jest.fn((...args) => {
173
+ // Always show errors, especially in debug mode.
174
+ // Could add filtering if there are known, non-critical errors to ignore.
175
+ if (process.env.DEBUG) {
176
+ originalError(...args);
177
+ }
178
+ // Potentially log even without DEBUG for visibility during tests
179
+ // originalError(...args);
180
+ });
181
+
182
+ console.log("Test setup complete."); // Log setup finish
183
+ });
184
+
185
+ /**
186
+ * Jest's global afterAll hook. Runs once after all tests in a suite file have completed.
187
+ * Cleans up the temporary directory created for the test run.
188
+ */
189
+ afterAll(async () => {
190
+ console.log(`Cleaning up test environment in: ${tempDir}`); // Log cleanup start
191
+ const dirs = global.__TEST_DIRECTORIES__; // Retrieve paths from global scope
192
+ if (!dirs) {
193
+ console.warn("Global test directories not found during cleanup.");
194
+ return;
195
+ }
196
+
197
+ try {
198
+ // Recursively remove the entire temporary directory for this test run.
199
+ // Using force: true helps ignore errors if files are missing (e.g., cleanup already ran partially).
200
+ await fs.rm(tempDir, { recursive: true, force: true });
201
+ console.log(`Successfully cleaned up test directory: ${tempDir}`);
202
+ } catch (e) {
203
+ // Log errors during cleanup but don't fail the tests typically.
204
+ // ENOENT means the directory was already gone, which is fine.
205
+ if (e.code !== 'ENOENT') {
206
+ console.warn(`Could not fully clean test directory ${tempDir}:`, e.message);
207
+ } else {
208
+ console.log(`Test directory already removed: ${tempDir}`);
209
+ }
210
+ }
211
+ });
package/nodemon.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "watch": ["src"],
3
+ "ext": "ts,tsx",
4
+ "ignore": [
5
+ "src/**/*.spec.ts",
6
+ "src/**/*.test.ts",
7
+ "dist",
8
+ "node_modules"
9
+ ],
10
+ "exec": "npm run docs:api"
11
+ }
package/output.html ADDED
@@ -0,0 +1 @@
1
+ <html>Warning</html>
package/package.json ADDED
@@ -0,0 +1,161 @@
1
+ {
2
+ "name": "portapack",
3
+ "version": "0.2.1",
4
+ "description": "šŸ“¦ A tool to bundle and minify HTML and all its dependencies into a single portable file.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "type": "module",
9
+ "bin": {
10
+ "portapack": "./dist/cli/cli-entry.js"
11
+ },
12
+ "scripts": {
13
+ "dev": "concurrently --success=all -n BUILD,DOCS,TEST -c green,blue,magenta \"npm run dev:build\" \"npm run dev:docs\" \"npm run dev:test\"",
14
+ "dev:build": "tsup --watch",
15
+ "dev:docs": "concurrently \"npm run docs:api:watch\" \"npm run docs:dev\"",
16
+ "dev:test": "cross-env FORCE_COLOR=1 jest --watch --clearCache --passWithNoTests --watchPathIgnorePatterns=\"<rootDir>/tests/__fixtures__/output\"",
17
+ "dev:test:debug": "cross-env NODE_OPTIONS=--experimental-vm-modules FORCE_COLOR=1 jest --watch --runTestsByPath",
18
+ "build": "npm run build:code && npm run docs:api && npm run docs:build",
19
+ "build:code": "tsup",
20
+ "example": "npx tsx examples/main.ts",
21
+ "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage",
22
+ "test:ci": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage --ci",
23
+ "test:clear": "jest --clearCache",
24
+ "coverage": "jest --coverage && open coverage/lcov-report/index.html",
25
+ "docs:coverage": "npm run test && mkdir -p docs/test-coverage && cp -r coverage/lcov-report/* docs/test-coverage/",
26
+ "prepare": "npm run build",
27
+ "publish:npm": "npm publish",
28
+ "lint": "eslint . --ext .ts,.js",
29
+ "format": "prettier --write \"**/*.{ts,js,json,md}\"",
30
+ "format:check": "prettier --check \"**/*.{ts,js,json,md}\"",
31
+ "commit": "git-cz",
32
+ "semantic-release": "semantic-release",
33
+ "coveralls": "jest --coverage && coveralls < coverage/lcov.info",
34
+ "docs:dev": "vitepress dev docs",
35
+ "docs:api": "typedoc --options typedoc.json",
36
+ "docs:api:watch": "nodemon --watch src --ext ts,tsx --exec \"npm run docs:api\"",
37
+ "docs:build": "vitepress build docs",
38
+ "docs:preview": "vitepress preview docs",
39
+ "docs:deploy": "npm run docs:api && vitepress build docs && gh-pages -d docs/.vitepress/dist",
40
+ "docs:serve": "npm run docs:api && vitepress build docs && vitepress serve docs"
41
+ },
42
+ "keywords": [
43
+ "html",
44
+ "bundler",
45
+ "portable",
46
+ "minify",
47
+ "packer",
48
+ "assets",
49
+ "cli",
50
+ "offline",
51
+ "webapp",
52
+ "static-site",
53
+ "bundle",
54
+ "recursive"
55
+ ],
56
+ "author": "Manic.agency",
57
+ "license": "MIT",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/manicinc/portapack.git"
61
+ },
62
+ "bugs": {
63
+ "url": "https://github.com/manicinc/portapack/issues"
64
+ },
65
+ "homepage": "https://github.com/manicinc/portapack#readme",
66
+ "dependencies": {
67
+ "axios": "^1.8.4",
68
+ "chalk": "^5.4.1",
69
+ "cheerio": "^1.0.0",
70
+ "clean-css": "^5.3.3",
71
+ "commander": "^13.0.0",
72
+ "html-minifier-terser": "^7.2.0",
73
+ "puppeteer": "^24.6.0",
74
+ "terser": "^5.39.0"
75
+ },
76
+ "devDependencies": {
77
+ "@commitlint/cli": "^19.0.0",
78
+ "@commitlint/config-conventional": "^19.0.0",
79
+ "@semantic-release/changelog": "^6.0.3",
80
+ "@semantic-release/git": "^10.0.1",
81
+ "@types/clean-css": "^4.2.11",
82
+ "@types/html-minifier-terser": "^7.0.2",
83
+ "@types/jest": "^29.5.14",
84
+ "@types/node": "^22.13.14",
85
+ "@typescript-eslint/eslint-plugin": "^7.0.0",
86
+ "@typescript-eslint/parser": "^7.0.0",
87
+ "commitizen": "^4.3.0",
88
+ "concurrently": "^9.1.2",
89
+ "coveralls": "^3.1.1",
90
+ "cross-env": "^7.0.3",
91
+ "cz-conventional-changelog": "^3.3.0",
92
+ "eslint": "^8.57.0",
93
+ "eslint-config-prettier": "^9.0.0",
94
+ "eslint-plugin-jest": "^27.9.0",
95
+ "execa": "^9.5.2",
96
+ "gh-pages": "^6.3.0",
97
+ "glob": "^11.0.1",
98
+ "husky": "^9.0.0",
99
+ "jest": "^29.7.0",
100
+ "jest-environment-node": "^29.7.0",
101
+ "jest-watch-typeahead": "^2.2.2",
102
+ "lint-staged": "^15.2.0",
103
+ "nodemon": "^3.1.9",
104
+ "prettier": "^3.2.5",
105
+ "semantic-release": "^23.0.0",
106
+ "ts-jest": "^29.3.1",
107
+ "ts-node": "^10.9.2",
108
+ "tsup": "^8.4.0",
109
+ "typedoc": "^0.28.1",
110
+ "typedoc-plugin-markdown": "^4.6.0",
111
+ "typescript": "^5.8.2",
112
+ "vitepress": "^1.6.3"
113
+ },
114
+ "config": {
115
+ "commitizen": {
116
+ "path": "./node_modules/cz-conventional-changelog"
117
+ }
118
+ },
119
+ "husky": {
120
+ "hooks": {
121
+ "pre-commit": "lint-staged",
122
+ "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
123
+ }
124
+ },
125
+ "lint-staged": {
126
+ "*.{ts,js}": [
127
+ "eslint --fix",
128
+ "prettier --write"
129
+ ],
130
+ "*.{json,md}": [
131
+ "prettier --write"
132
+ ]
133
+ },
134
+ "commitlint": {
135
+ "extends": [
136
+ "@commitlint/config-conventional"
137
+ ]
138
+ },
139
+ "release": {
140
+ "branches": [
141
+ "master"
142
+ ],
143
+ "plugins": [
144
+ "@semantic-release/commit-analyzer",
145
+ "@semantic-release/release-notes-generator",
146
+ "@semantic-release/changelog",
147
+ "@semantic-release/npm",
148
+ "@semantic-release/github",
149
+ [
150
+ "@semantic-release/git",
151
+ {
152
+ "assets": [
153
+ "CHANGELOG.md",
154
+ "package.json"
155
+ ],
156
+ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
157
+ }
158
+ ]
159
+ ]
160
+ }
161
+ }
@@ -0,0 +1 @@
1
+ <html>Generated Recursive Number</html>
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file cli-entry.ts
3
+ * @description
4
+ * Node.js entry point for PortaPack CLI (compatible with ESM).
5
+ *
6
+ * Supports:
7
+ * - Direct execution: `node cli-entry.js`
8
+ * - Programmatic import for testing: `import { startCLI } from './cli-entry'`
9
+ */
10
+
11
+ import type { CLIResult } from '../types';
12
+
13
+ /**
14
+ * Starts the CLI by importing and invoking the main CLI logic.
15
+ *
16
+ * @returns {Promise<CLIResult>} - Exit code and any captured output
17
+ */
18
+ const startCLI = async (): Promise<CLIResult> => {
19
+ const { main } = await import('./cli.js');
20
+ return await main(process.argv);
21
+ };
22
+
23
+ // If executed directly from the command line, run and exit.
24
+ if (import.meta.url === `file://${process.argv[1]}`) {
25
+ startCLI().then(({ exitCode }) => process.exit(Number(exitCode))); // Cast exitCode to Number
26
+ }
27
+
28
+ export { startCLI };
package/src/cli/cli.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @file cli.ts
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.
6
+ */
7
+
8
+ import fs from 'fs'; // Use default import if mocking default below
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ import { parseOptions } from './options.js';
13
+ import { generatePortableHTML, generateRecursivePortableHTML } from '../index';
14
+ import type { CLIResult } from '../types';
15
+
16
+ import { LogLevel } from '../types';
17
+
18
+ /**
19
+ * Dynamically loads package.json metadata.
20
+ */
21
+ 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
35
+ }
36
+
37
+ /**
38
+ * Entry function for running the CLI.
39
+ */
40
+ 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;
128
+ }
129
+
130
+ return { stdout, stderr, exitCode };
131
+ }
132
+
133
+ // Optional: Define main export if this file is intended to be run directly
134
+ export const main = runCli;
135
+
136
+ // Example direct execution (usually handled by bin entry in package.json)
137
+ // if (require.main === module) {
138
+ // runCli();
139
+ // }