portapack 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.eslintrc.json +67 -8
  2. package/.releaserc.js +25 -27
  3. package/CHANGELOG.md +14 -22
  4. package/LICENSE.md +21 -0
  5. package/README.md +22 -53
  6. package/commitlint.config.js +30 -34
  7. package/dist/cli/cli-entry.cjs +183 -98
  8. package/dist/cli/cli-entry.cjs.map +1 -1
  9. package/dist/index.d.ts +0 -3
  10. package/dist/index.js +178 -97
  11. package/dist/index.js.map +1 -1
  12. package/docs/.vitepress/config.ts +38 -33
  13. package/docs/.vitepress/sidebar-generator.ts +89 -38
  14. package/docs/architecture.md +186 -0
  15. package/docs/cli.md +23 -23
  16. package/docs/code-of-conduct.md +7 -1
  17. package/docs/configuration.md +12 -11
  18. package/docs/contributing.md +6 -2
  19. package/docs/deployment.md +10 -5
  20. package/docs/development.md +8 -5
  21. package/docs/getting-started.md +13 -13
  22. package/docs/index.md +1 -1
  23. package/docs/public/android-chrome-192x192.png +0 -0
  24. package/docs/public/android-chrome-512x512.png +0 -0
  25. package/docs/public/apple-touch-icon.png +0 -0
  26. package/docs/public/favicon-16x16.png +0 -0
  27. package/docs/public/favicon-32x32.png +0 -0
  28. package/docs/public/favicon.ico +0 -0
  29. package/docs/roadmap.md +233 -0
  30. package/docs/site.webmanifest +1 -0
  31. package/docs/troubleshooting.md +12 -1
  32. package/examples/main.ts +5 -30
  33. package/examples/sample-project/script.js +1 -1
  34. package/jest.config.ts +8 -13
  35. package/nodemon.json +5 -10
  36. package/package.json +2 -5
  37. package/src/cli/cli-entry.ts +2 -2
  38. package/src/cli/cli.ts +21 -16
  39. package/src/cli/options.ts +127 -113
  40. package/src/core/bundler.ts +253 -222
  41. package/src/core/extractor.ts +632 -565
  42. package/src/core/minifier.ts +173 -162
  43. package/src/core/packer.ts +141 -137
  44. package/src/core/parser.ts +74 -73
  45. package/src/core/web-fetcher.ts +270 -258
  46. package/src/index.ts +18 -17
  47. package/src/types.ts +9 -11
  48. package/src/utils/font.ts +12 -6
  49. package/src/utils/logger.ts +110 -105
  50. package/src/utils/meta.ts +75 -76
  51. package/src/utils/mime.ts +50 -50
  52. package/src/utils/slugify.ts +33 -34
  53. package/tests/unit/cli/cli-entry.test.ts +72 -70
  54. package/tests/unit/cli/cli.test.ts +314 -278
  55. package/tests/unit/cli/options.test.ts +294 -301
  56. package/tests/unit/core/bundler.test.ts +426 -329
  57. package/tests/unit/core/extractor.test.ts +793 -549
  58. package/tests/unit/core/minifier.test.ts +374 -274
  59. package/tests/unit/core/packer.test.ts +298 -264
  60. package/tests/unit/core/parser.test.ts +538 -150
  61. package/tests/unit/core/web-fetcher.test.ts +389 -359
  62. package/tests/unit/index.test.ts +238 -197
  63. package/tests/unit/utils/font.test.ts +26 -21
  64. package/tests/unit/utils/logger.test.ts +267 -260
  65. package/tests/unit/utils/meta.test.ts +29 -28
  66. package/tests/unit/utils/mime.test.ts +73 -74
  67. package/tests/unit/utils/slugify.test.ts +14 -12
  68. package/tsconfig.build.json +9 -10
  69. package/tsconfig.jest.json +1 -1
  70. package/tsconfig.json +2 -2
  71. package/tsup.config.ts +8 -9
  72. package/typedoc.json +5 -9
  73. /package/docs/{portapack-transparent.png → public/portapack-transparent.png} +0 -0
  74. /package/docs/{portapack.jpg → public/portapack.jpg} +0 -0
@@ -14,350 +14,447 @@ import { pathToFileURL } from 'url'; // Import pathToFileURL
14
14
 
15
15
  // === Mocked Modules (Using standard jest.mock) ===
16
16
  // Define mock functions first WITH EXPLICIT TYPES from previous fix
17
- const mockExtractAssetsFn = jest.fn<(parsed: ParsedHTML, embed: boolean, baseUrl: string, logger: Logger) => Promise<ParsedHTML>>();
18
- const mockMinifyAssetsFn = jest.fn<(parsed: ParsedHTML, opts: BundleOptions, logger: Logger) => Promise<ParsedHTML>>();
17
+ const mockExtractAssetsFn =
18
+ jest.fn<
19
+ (parsed: ParsedHTML, embed: boolean, baseUrl: string, logger: Logger) => Promise<ParsedHTML>
20
+ >();
21
+ const mockMinifyAssetsFn =
22
+ jest.fn<(parsed: ParsedHTML, opts: BundleOptions, logger: Logger) => Promise<ParsedHTML>>();
19
23
  const mockPackHTMLFn = jest.fn<(parsed: ParsedHTML, logger: Logger) => string>();
20
24
 
21
25
  // Use standard jest.mock with factories BEFORE imports
22
26
  jest.mock('../../../src/core/extractor', () => ({
23
- __esModule: true,
24
- extractAssets: mockExtractAssetsFn,
27
+ __esModule: true,
28
+ extractAssets: mockExtractAssetsFn,
25
29
  }));
26
30
  jest.mock('../../../src/core/minifier', () => ({
27
- __esModule: true,
28
- minifyAssets: mockMinifyAssetsFn,
31
+ __esModule: true,
32
+ minifyAssets: mockMinifyAssetsFn,
29
33
  }));
30
34
  jest.mock('../../../src/core/packer', () => ({
31
- __esModule: true,
32
- packHTML: mockPackHTMLFn,
35
+ __esModule: true,
36
+ packHTML: mockPackHTMLFn,
33
37
  }));
34
38
 
35
39
  // === Import After Mock Setup (Using standard import) ===
36
40
  import { bundleSingleHTML, bundleMultiPageHTML } from '../../../src/core/bundler';
37
41
 
38
-
39
42
  describe('🧩 Core Bundler', () => {
40
- let mockLogger: Logger;
41
- let mockLoggerDebugSpy: ReturnType<typeof jest.spyOn>;
42
- let mockLoggerInfoSpy: ReturnType<typeof jest.spyOn>;
43
- let mockLoggerErrorSpy: ReturnType<typeof jest.spyOn>;
44
- let mockLoggerWarnSpy: ReturnType<typeof jest.spyOn>;
45
-
46
- // --- Test Constants ---
47
- const defaultParsed: ParsedHTML = {
48
- htmlContent: '<html><head><link href="style.css"></head><body><script src="app.js"></script></body></html>',
49
- assets: [
50
- { type: 'css', url: 'style.css' },
51
- { type: 'js', url: 'app.js' }
52
- ] as Asset[]
53
- };
54
- const defaultExtracted: ParsedHTML = {
55
- htmlContent: defaultParsed.htmlContent,
56
- assets: [
57
- { type: 'css', url: 'style.css', content: 'body{color:red}' },
58
- { type: 'js', url: 'app.js', content: 'console.log(1)' }
59
- ] as Asset[]
60
- };
61
- const defaultMinified: ParsedHTML = {
62
- htmlContent: '<html><head></head><body><h1>minified</h1></body></html>',
63
- assets: defaultExtracted.assets
64
- };
65
- const defaultPacked = '<!DOCTYPE html><html>...packed...</html>';
66
-
67
- const trickyPages: PageEntry[] = [
68
- { url: 'products/item-1%.html', html: 'Item 1' },
69
- { url: 'search?q=test&page=2', html: 'Search Results' },
70
- { url: '/ path / page .html ', html: 'Spaced Page' },
71
- { url: '/leading--and--trailing/', html: 'Leading Trailing' },
72
- { url: '///multiple////slashes///page', html: 'Multiple Slashes' }
73
- ];
74
- // --- End Constants ---
75
-
76
- beforeEach(() => {
77
- jest.clearAllMocks();
78
-
79
- // Configure using the direct mock function variables
80
- mockExtractAssetsFn.mockResolvedValue(defaultExtracted);
81
- mockMinifyAssetsFn.mockResolvedValue(defaultMinified);
82
- mockPackHTMLFn.mockReturnValue(defaultPacked);
83
-
84
- // Use WARN level usually, DEBUG if specifically testing debug logs
85
- mockLogger = new Logger(LogLevel.WARN);
86
- mockLoggerDebugSpy = jest.spyOn(mockLogger, 'debug');
87
- mockLoggerInfoSpy = jest.spyOn(mockLogger, 'info');
88
- mockLoggerErrorSpy = jest.spyOn(mockLogger, 'error');
89
- mockLoggerWarnSpy = jest.spyOn(mockLogger, 'warn');
43
+ let mockLogger: Logger;
44
+ let mockLoggerDebugSpy: ReturnType<typeof jest.spyOn>;
45
+ let mockLoggerInfoSpy: ReturnType<typeof jest.spyOn>;
46
+ let mockLoggerErrorSpy: ReturnType<typeof jest.spyOn>;
47
+ let mockLoggerWarnSpy: ReturnType<typeof jest.spyOn>;
48
+
49
+ // --- Test Constants ---
50
+ const defaultParsed: ParsedHTML = {
51
+ htmlContent:
52
+ '<html><head><link href="style.css"></head><body><script src="app.js"></script></body></html>',
53
+ assets: [
54
+ { type: 'css', url: 'style.css' },
55
+ { type: 'js', url: 'app.js' },
56
+ ] as Asset[],
57
+ };
58
+ const defaultExtracted: ParsedHTML = {
59
+ htmlContent: defaultParsed.htmlContent,
60
+ assets: [
61
+ { type: 'css', url: 'style.css', content: 'body{color:red}' },
62
+ { type: 'js', url: 'app.js', content: 'console.log(1)' },
63
+ ] as Asset[],
64
+ };
65
+ const defaultMinified: ParsedHTML = {
66
+ htmlContent: '<html><head></head><body><h1>minified</h1></body></html>',
67
+ assets: defaultExtracted.assets,
68
+ };
69
+ const defaultPacked = '<!DOCTYPE html><html>...packed...</html>';
70
+
71
+ const trickyPages: PageEntry[] = [
72
+ { url: 'products/item-1%.html', html: 'Item 1' },
73
+ { url: 'search?q=test&page=2', html: 'Search Results' },
74
+ { url: '/ path / page .html ', html: 'Spaced Page' },
75
+ { url: '/leading--and--trailing/', html: 'Leading Trailing' },
76
+ { url: '///multiple////slashes///page', html: 'Multiple Slashes' },
77
+ ];
78
+ // --- End Constants ---
79
+
80
+ beforeEach(() => {
81
+ jest.clearAllMocks();
82
+
83
+ // Configure using the direct mock function variables
84
+ mockExtractAssetsFn.mockResolvedValue(defaultExtracted);
85
+ mockMinifyAssetsFn.mockResolvedValue(defaultMinified);
86
+ mockPackHTMLFn.mockReturnValue(defaultPacked);
87
+
88
+ // Use WARN level usually, DEBUG if specifically testing debug logs
89
+ mockLogger = new Logger(LogLevel.WARN);
90
+ mockLoggerDebugSpy = jest.spyOn(mockLogger, 'debug');
91
+ mockLoggerInfoSpy = jest.spyOn(mockLogger, 'info');
92
+ mockLoggerErrorSpy = jest.spyOn(mockLogger, 'error');
93
+ mockLoggerWarnSpy = jest.spyOn(mockLogger, 'warn');
94
+ });
95
+
96
+ // ========================
97
+ // === bundleSingleHTML ===
98
+ // ========================
99
+ describe('bundleSingleHTML()', () => {
100
+ it('should call extract, minify, and pack in order', async () => {
101
+ await bundleSingleHTML(defaultParsed, 'src/index.html', {}, mockLogger);
102
+ expect(mockExtractAssetsFn).toHaveBeenCalledTimes(1);
103
+ expect(mockMinifyAssetsFn).toHaveBeenCalledTimes(1);
104
+ expect(mockPackHTMLFn).toHaveBeenCalledTimes(1);
105
+
106
+ const extractOrder = mockExtractAssetsFn.mock.invocationCallOrder[0];
107
+ const minifyOrder = mockMinifyAssetsFn.mock.invocationCallOrder[0];
108
+ const packOrder = mockPackHTMLFn.mock.invocationCallOrder[0];
109
+ expect(extractOrder).toBeLessThan(minifyOrder);
110
+ expect(minifyOrder).toBeLessThan(packOrder);
111
+ });
112
+
113
+ it('should pass correct arguments to dependencies', async () => {
114
+ const options: BundleOptions = { embedAssets: false, baseUrl: 'https://test.com/' };
115
+ await bundleSingleHTML(defaultParsed, 'https://test.com/page.html', options, mockLogger);
116
+
117
+ // Check call to extractAssets
118
+ expect(mockExtractAssetsFn).toHaveBeenCalledWith(
119
+ defaultParsed,
120
+ false,
121
+ 'https://test.com/',
122
+ mockLogger
123
+ );
124
+
125
+ // Check call to minifyAssets more specifically
126
+ expect(mockMinifyAssetsFn).toHaveBeenCalledTimes(1); // Make sure it was called
127
+ const minifyArgs = mockMinifyAssetsFn.mock.calls[0];
128
+ expect(minifyArgs[0]).toEqual(defaultExtracted); // Check the first argument (parsed data)
129
+
130
+ // Check the second argument (options object)
131
+ const receivedOptions = minifyArgs[1] as BundleOptions; // Cast the received arg for type checking
132
+ expect(receivedOptions).toBeDefined();
133
+ // Assert specific properties passed in 'options'
134
+ expect(receivedOptions.embedAssets).toBe(options.embedAssets);
135
+ expect(receivedOptions.baseUrl).toBe(options.baseUrl);
136
+ // Also check that default options were likely merged in (optional but good)
137
+ expect(receivedOptions.minifyHtml).toBe(true); // Assuming true is the default
138
+ expect(receivedOptions.minifyCss).toBe(true); // Assuming true is the default
139
+ expect(receivedOptions.minifyJs).toBe(true); // Assuming true is the default
140
+
141
+ // Check the third argument (logger)
142
+ expect(minifyArgs[2]).toBe(mockLogger);
143
+
144
+ // Check call to packHTML
145
+ expect(mockPackHTMLFn).toHaveBeenCalledWith(defaultMinified, mockLogger);
146
+ });
147
+
148
+ it('should return packed HTML from packHTML()', async () => {
149
+ const result = await bundleSingleHTML(defaultParsed, 'src/index.html', {}, mockLogger);
150
+ expect(result).toBe(defaultPacked);
151
+ });
152
+
153
+ it('should determine and use correct file base URL if none provided', async () => {
154
+ // Set logger level to DEBUG for this specific test
155
+ mockLogger.level = LogLevel.DEBUG;
156
+ const inputPath = path.normalize('./some/dir/file.html');
157
+ const absoluteDir = path.resolve(path.dirname(inputPath));
158
+ const expectedBase = pathToFileURL(absoluteDir + path.sep).href;
159
+
160
+ await bundleSingleHTML(defaultParsed, inputPath, {}, mockLogger);
161
+
162
+ // Check the specific log message more robustly
163
+ const debugCalls = mockLoggerDebugSpy.mock.calls;
164
+ // Add type annotation here
165
+ expect(
166
+ debugCalls.some((call: any[]) =>
167
+ call[0].includes(`Determined local base URL: ${expectedBase}`)
168
+ )
169
+ ).toBe(true);
170
+ // Check arguments passed to extractAssets
171
+ expect(mockExtractAssetsFn).toHaveBeenCalledWith(
172
+ defaultParsed,
173
+ true,
174
+ expectedBase,
175
+ mockLogger
176
+ );
90
177
  });
91
178
 
92
- // ========================
93
- // === bundleSingleHTML ===
94
- // ========================
95
- describe('bundleSingleHTML()', () => {
96
- it('should call extract, minify, and pack in order', async () => {
97
- await bundleSingleHTML(defaultParsed, 'src/index.html', {}, mockLogger);
98
- expect(mockExtractAssetsFn).toHaveBeenCalledTimes(1);
99
- expect(mockMinifyAssetsFn).toHaveBeenCalledTimes(1);
100
- expect(mockPackHTMLFn).toHaveBeenCalledTimes(1);
101
-
102
- const extractOrder = mockExtractAssetsFn.mock.invocationCallOrder[0];
103
- const minifyOrder = mockMinifyAssetsFn.mock.invocationCallOrder[0];
104
- const packOrder = mockPackHTMLFn.mock.invocationCallOrder[0];
105
- expect(extractOrder).toBeLessThan(minifyOrder);
106
- expect(minifyOrder).toBeLessThan(packOrder);
107
- });
108
-
109
- it('should pass correct arguments to dependencies', async () => {
110
- const options: BundleOptions = { embedAssets: false, baseUrl: 'https://test.com/' };
111
- await bundleSingleHTML(defaultParsed, 'https://test.com/page.html', options, mockLogger);
112
-
113
- // Check call to extractAssets
114
- expect(mockExtractAssetsFn).toHaveBeenCalledWith(defaultParsed, false, 'https://test.com/', mockLogger);
115
-
116
- // Check call to minifyAssets more specifically
117
- expect(mockMinifyAssetsFn).toHaveBeenCalledTimes(1); // Make sure it was called
118
- const minifyArgs = mockMinifyAssetsFn.mock.calls[0];
119
- expect(minifyArgs[0]).toEqual(defaultExtracted); // Check the first argument (parsed data)
120
-
121
- // Check the second argument (options object)
122
- const receivedOptions = minifyArgs[1] as BundleOptions; // Cast the received arg for type checking
123
- expect(receivedOptions).toBeDefined();
124
- // Assert specific properties passed in 'options'
125
- expect(receivedOptions.embedAssets).toBe(options.embedAssets);
126
- expect(receivedOptions.baseUrl).toBe(options.baseUrl);
127
- // Also check that default options were likely merged in (optional but good)
128
- expect(receivedOptions.minifyHtml).toBe(true); // Assuming true is the default
129
- expect(receivedOptions.minifyCss).toBe(true); // Assuming true is the default
130
- expect(receivedOptions.minifyJs).toBe(true); // Assuming true is the default
131
-
132
- // Check the third argument (logger)
133
- expect(minifyArgs[2]).toBe(mockLogger);
134
-
135
- // Check call to packHTML
136
- expect(mockPackHTMLFn).toHaveBeenCalledWith(defaultMinified, mockLogger);
137
- });
138
-
139
-
140
- it('should return packed HTML from packHTML()', async () => {
141
- const result = await bundleSingleHTML(defaultParsed, 'src/index.html', {}, mockLogger);
142
- expect(result).toBe(defaultPacked);
143
- });
144
-
145
- it('should determine and use correct file base URL if none provided', async () => {
146
- // Set logger level to DEBUG for this specific test
147
- mockLogger.level = LogLevel.DEBUG;
148
- const inputPath = path.normalize('./some/dir/file.html');
149
- const absoluteDir = path.resolve(path.dirname(inputPath));
150
- const expectedBase = pathToFileURL(absoluteDir + path.sep).href;
151
-
152
- await bundleSingleHTML(defaultParsed, inputPath, {}, mockLogger);
153
-
154
- // Check the specific log message more robustly
155
- const debugCalls = mockLoggerDebugSpy.mock.calls;
156
- // Add type annotation here
157
- expect(debugCalls.some((call: any[]) => call[0].includes(`Determined local base URL: ${expectedBase}`))).toBe(true);
158
- // Check arguments passed to extractAssets
159
- expect(mockExtractAssetsFn).toHaveBeenCalledWith(defaultParsed, true, expectedBase, mockLogger);
160
- });
161
-
162
- it('should determine and use correct HTTP base URL if none provided', async () => {
163
- // Set logger level to DEBUG for this specific test
164
- mockLogger.level = LogLevel.DEBUG;
165
- const inputUrl = 'https://example.com/path/to/page.html?foo=bar';
166
- const expectedBase = 'https://example.com/path/to/';
167
-
168
- await bundleSingleHTML(defaultParsed, inputUrl, {}, mockLogger);
169
-
170
- // Check the specific log message more robustly ("remote" not "HTTP")
171
- const debugCalls = mockLoggerDebugSpy.mock.calls;
172
- // Add type annotation here
173
- expect(debugCalls.some((call: any[]) => call[0].includes(`Determined remote base URL: ${expectedBase}`))).toBe(true);
174
- expect(mockExtractAssetsFn).toHaveBeenCalledWith(defaultParsed, true, expectedBase, mockLogger);
175
- });
176
-
177
-
178
- it('should use provided baseUrl option', async () => {
179
- const providedBase = 'https://cdn.example.com/assets/';
180
- await bundleSingleHTML(defaultParsed, 'local/file.html', { baseUrl: providedBase }, mockLogger);
181
- expect(mockExtractAssetsFn).toHaveBeenCalledWith(defaultParsed, true, providedBase, mockLogger);
182
- });
183
-
184
-
185
- it('should propagate errors from extract/minify/pack', async () => {
186
- const errorOrigin = 'file.html';
187
- mockPackHTMLFn.mockImplementationOnce(() => { throw new Error('Boom from pack'); });
188
- await expect(bundleSingleHTML(defaultParsed, errorOrigin, {}, mockLogger)).rejects.toThrow('Boom from pack');
189
- // Check the specific error message logged by bundler.ts
190
- expect(mockLoggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining(`Error during single HTML bundling for ${errorOrigin}: Boom from pack`));
191
- mockLoggerErrorSpy.mockClear();
192
-
193
- mockMinifyAssetsFn.mockImplementationOnce(async () => { throw new Error('Boom from minify'); });
194
- await expect(bundleSingleHTML(defaultParsed, errorOrigin, {}, mockLogger)).rejects.toThrow('Boom from minify');
195
- expect(mockLoggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining(`Error during single HTML bundling for ${errorOrigin}: Boom from minify`));
196
- mockLoggerErrorSpy.mockClear();
197
-
198
- mockExtractAssetsFn.mockImplementationOnce(async () => { throw new Error('Boom from extract'); });
199
- await expect(bundleSingleHTML(defaultParsed, errorOrigin, {}, mockLogger)).rejects.toThrow('Boom from extract');
200
- expect(mockLoggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining(`Error during single HTML bundling for ${errorOrigin}: Boom from extract`));
201
- });
179
+ it('should determine and use correct HTTP base URL if none provided', async () => {
180
+ // Set logger level to DEBUG for this specific test
181
+ mockLogger.level = LogLevel.DEBUG;
182
+ const inputUrl = 'https://example.com/path/to/page.html?foo=bar';
183
+ const expectedBase = 'https://example.com/path/to/';
184
+
185
+ await bundleSingleHTML(defaultParsed, inputUrl, {}, mockLogger);
186
+
187
+ // Check the specific log message more robustly ("remote" not "HTTP")
188
+ const debugCalls = mockLoggerDebugSpy.mock.calls;
189
+ // Add type annotation here
190
+ expect(
191
+ debugCalls.some((call: any[]) =>
192
+ call[0].includes(`Determined remote base URL: ${expectedBase}`)
193
+ )
194
+ ).toBe(true);
195
+ expect(mockExtractAssetsFn).toHaveBeenCalledWith(
196
+ defaultParsed,
197
+ true,
198
+ expectedBase,
199
+ mockLogger
200
+ );
202
201
  });
203
202
 
204
- // ============================
205
- // === bundleMultiPageHTML ===
206
- // ============================
207
- // Note: If heap exhaustion occurs, you may need to run with --runInBand
208
- // and potentially comment out tests within this suite to isolate the cause.
209
- describe('bundleMultiPageHTML()', () => {
210
-
211
- // TODO: This test fails because the actual slug for '///multiple////slashes///page'
212
- // needs to be determined (e.g., via logging in bundler.ts) and updated below.
213
- it.skip('should sanitize tricky URLs into slugs', () => { // <--- SKIPPED FOR NOW
214
- const html = bundleMultiPageHTML(trickyPages, mockLogger);
215
- const $ = cheerio.load(html);
216
- // console.log('DEBUG Tricky Slugs HTML:\n', $.html()); // Uncomment to inspect HTML if needed
217
-
218
- expect($('template#page-products-item-1').length).toBe(1);
219
- expect($('template#page-search-q-test-page-2').length).toBe(1);
220
- expect($('template#page-path-page').length).toBe(1); // Assumes sanitizeSlug handles spaces/dots
221
- expect($('template#page-leading-and-trailing').length).toBe(1); // Assumes sanitizeSlug trims
222
-
223
- // === IMPORTANT ===
224
- // Verify the actual slug generated by your sanitizeSlug implementation
225
- // for '///multiple////slashes///page' using logging if needed.
226
- const multipleSlashesSlug = 'multiple-slashes-page'; // <-- *** REPLACE THIS PLACEHOLDER with actual slug ***
227
- // =================
228
-
229
- const multipleSlashesTemplate = $(`template#page-${multipleSlashesSlug}`);
230
- expect(multipleSlashesTemplate.length).toBe(1); // Check if template with expected ID exists
231
- expect(multipleSlashesTemplate.html()).toBe('Multiple Slashes'); // Check its content
232
- });
233
-
234
- it('should include router script with navigateTo()', () => {
235
- const pages: PageEntry[] = [{ url: 'index.html', html: '<h1>Hello</h1>' }];
236
- const html = bundleMultiPageHTML(pages, mockLogger);
237
- const $ = cheerio.load(html);
238
- const routerScript = $('#router-script').html();
239
- expect(routerScript).toContain('function navigateTo');
240
- expect(routerScript).toContain('navigateTo(initialSlug);');
241
- });
242
-
243
-
244
- it('should set default page to first valid entry slug', () => {
245
- const pages: PageEntry[] = [
246
- { url: 'home.html', html: '<h1>Home</h1>' },
247
- { url: 'about.html', html: '<h2>About</h2>' }
248
- ];
249
- const html = bundleMultiPageHTML(pages, mockLogger);
250
- // Default should be 'home' as it's the first valid slug and 'index' isn't present
251
- expect(html).toMatch(/const initialSlug = document\.getElementById\('page-' \+ initialHash\) \? initialHash : 'home'/);
252
- });
253
-
254
- it('should handle index.html or / as default page slug "index"', () => {
255
- const pages1: PageEntry[] = [{ url: 'index.html', html: 'Index' }, {url: 'other', html: 'Other'}];
256
- const html1 = bundleMultiPageHTML(pages1, mockLogger);
257
- // Expect 'index' as default when index.html is present
258
- expect(html1).toMatch(/const initialSlug = document\.getElementById\('page-' \+ initialHash\) \? initialHash : 'index'/);
259
-
260
- const pages2: PageEntry[] = [{ url: '/', html: 'Root Index' }, {url: 'other', html: 'Other'}];
261
- const html2 = bundleMultiPageHTML(pages2, mockLogger);
262
- // Expect 'index' as default when / is present
263
- expect(html2).toMatch(/const initialSlug = document\.getElementById\('page-' \+ initialHash\) \? initialHash : 'index'/);
264
-
265
- const pages3: PageEntry[] = [{ url: '/other/', html: 'Other' }, {url: '/index.html', html: 'Index Later'}];
266
- const html3 = bundleMultiPageHTML(pages3, mockLogger);
267
- // FIX: Expect 'index' as default because index.html IS present in the list
268
- expect(html3).toMatch(/const initialSlug = document\.getElementById\('page-' \+ initialHash\) \? initialHash : 'index'/);
269
- });
270
-
271
- it('should throw if input is not an array', () => {
272
- // @ts-expect-error - Testing invalid input
273
- expect(() => bundleMultiPageHTML(null, mockLogger)).toThrow(/must be an array/);
274
- expect(mockLoggerErrorSpy).toHaveBeenCalled();
275
- });
276
-
277
- it('should throw if pages array is empty', () => {
278
- expect(() => bundleMultiPageHTML([], mockLogger)).toThrow(/No valid page entries/);
279
- expect(mockLoggerErrorSpy).toHaveBeenCalled();
280
- });
281
-
282
- it('should throw if all pages are invalid entries', () => {
283
- // @ts-expect-error - Testing invalid input array elements
284
- expect(() => bundleMultiPageHTML([null, undefined, {}, {url: 'nohtml'}, {html: 'nourl'}], mockLogger)).toThrow(/No valid page entries/);
285
- expect(mockLoggerErrorSpy).toHaveBeenCalled();
286
- });
287
-
288
- it('should log warning and skip invalid entries', () => {
289
- // Define the 'pages' array explicitly for this test
290
- const pages: any[] = [
291
- { url: 'one', html: 'Page 1 Content' }, // Valid (index 0)
292
- null, // Invalid (index 1)
293
- { url: 'missing_html' }, // Invalid (index 2, missing html)
294
- { html: 'missing_url' }, // Invalid (index 3, missing url)
295
- { url: 'two', html: 'Page 2 Content' } // Valid (index 4)
296
- ];
297
-
298
- const html = bundleMultiPageHTML(pages, mockLogger);
299
- const $ = cheerio.load(html);
300
-
301
- // Assertions based on the defined 'pages' array
302
- expect($('template').length).toBe(2); // Expect 2 valid pages rendered
303
- expect($('template#page-one').length).toBe(1); // Slug 'one' expected
304
- expect($('template#page-two').length).toBe(1); // Slug 'two' expected (assuming sanitizeSlug)
305
-
306
- // Check that warnings were logged for the invalid entries by their original index
307
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid page entry at index 1')); // for null
308
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid page entry at index 2')); // for { url: 'missing_html' }
309
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid page entry at index 3')); // for { html: 'missing_url' }
310
- expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(3); // Exactly 3 warnings
311
- });
312
-
313
- it('should generate nav links and container', () => {
314
- const pages: PageEntry[] = [
315
- { url: 'index.html', html: 'Index Content' },
316
- { url: 'about.html', html: 'About Content' },
317
- ];
318
- const html = bundleMultiPageHTML(pages, mockLogger);
319
- const $ = cheerio.load(html);
320
- expect($('#main-nav a').length).toBe(2);
321
- expect($('#page-container').length).toBe(1);
322
-
323
- // Check specific template ID and content for index.html
324
- expect($('template#page-index').length).toBe(1);
325
- expect($('template#page-index').html()).toBe('Index Content');
326
-
327
- expect($('template#page-about').length).toBe(1); // Check about template
328
-
329
- // Check nav link for index.html uses slug 'index'
330
- expect($('#main-nav a[href="#index"]').text()).toBe('index');
331
- // Check nav link for about.html uses slug 'about'
332
- expect($('#main-nav a[href="#about"]').text()).toBe('about');
333
- });
334
-
335
- it('should generate unique slugs on collision and log warning', () => {
336
- const pages: PageEntry[] = [
337
- { url: 'about.html', html: 'A1' }, // slug: about
338
- { url: '/about', html: 'A2' }, // slug: about -> about-1
339
- { url: 'about.htm', html: 'A3' } // slug: about -> about-1 (fail) -> about-2
340
- ];
341
- const html = bundleMultiPageHTML(pages, mockLogger);
342
- const $ = cheerio.load(html);
343
-
344
- expect($('template#page-about').length).toBe(1);
345
- expect($('template#page-about-1').length).toBe(1);
346
- expect($('template#page-about-2').length).toBe(1);
347
- expect($('#main-nav a[href="#about"]').length).toBe(1);
348
- expect($('#main-nav a[href="#about-1"]').length).toBe(1);
349
- expect($('#main-nav a[href="#about-2"]').length).toBe(1);
350
-
351
- // Match the exact warning messages logged by the implementation
352
- // Warning 1: /about collides with about, becomes about-1
353
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Slug collision detected for \"/about\" (intended slug: 'about'). Using \"about-1\" instead."));
354
- // Warning 2: about.htm collides with about, tries about-1
355
- // Warning 3: about.htm (still) collides with about-1, becomes about-2
356
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Slug collision detected for \"about.htm\" (intended slug: 'about'). Using \"about-1\" instead."));
357
- expect(mockLoggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Slug collision detected for \"about.htm\" (intended slug: 'about'). Using \"about-2\" instead."));
358
-
359
- // FIX: Expect 3 warnings total based on trace
360
- expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(3);
361
- });
362
- }); // End describe bundleMultiPageHTML
363
- }); // End describe Core Bundler
203
+ it('should use provided baseUrl option', async () => {
204
+ const providedBase = 'https://cdn.example.com/assets/';
205
+ await bundleSingleHTML(
206
+ defaultParsed,
207
+ 'local/file.html',
208
+ { baseUrl: providedBase },
209
+ mockLogger
210
+ );
211
+ expect(mockExtractAssetsFn).toHaveBeenCalledWith(
212
+ defaultParsed,
213
+ true,
214
+ providedBase,
215
+ mockLogger
216
+ );
217
+ });
218
+
219
+ it('should propagate errors from extract/minify/pack', async () => {
220
+ const errorOrigin = 'file.html';
221
+ mockPackHTMLFn.mockImplementationOnce(() => {
222
+ throw new Error('Boom from pack');
223
+ });
224
+ await expect(bundleSingleHTML(defaultParsed, errorOrigin, {}, mockLogger)).rejects.toThrow(
225
+ 'Boom from pack'
226
+ );
227
+ // Check the specific error message logged by bundler.ts
228
+ expect(mockLoggerErrorSpy).toHaveBeenCalledWith(
229
+ expect.stringContaining(
230
+ `Error during single HTML bundling for ${errorOrigin}: Boom from pack`
231
+ )
232
+ );
233
+ mockLoggerErrorSpy.mockClear();
234
+
235
+ mockMinifyAssetsFn.mockImplementationOnce(async () => {
236
+ throw new Error('Boom from minify');
237
+ });
238
+ await expect(bundleSingleHTML(defaultParsed, errorOrigin, {}, mockLogger)).rejects.toThrow(
239
+ 'Boom from minify'
240
+ );
241
+ expect(mockLoggerErrorSpy).toHaveBeenCalledWith(
242
+ expect.stringContaining(
243
+ `Error during single HTML bundling for ${errorOrigin}: Boom from minify`
244
+ )
245
+ );
246
+ mockLoggerErrorSpy.mockClear();
247
+
248
+ mockExtractAssetsFn.mockImplementationOnce(async () => {
249
+ throw new Error('Boom from extract');
250
+ });
251
+ await expect(bundleSingleHTML(defaultParsed, errorOrigin, {}, mockLogger)).rejects.toThrow(
252
+ 'Boom from extract'
253
+ );
254
+ expect(mockLoggerErrorSpy).toHaveBeenCalledWith(
255
+ expect.stringContaining(
256
+ `Error during single HTML bundling for ${errorOrigin}: Boom from extract`
257
+ )
258
+ );
259
+ });
260
+ });
261
+
262
+ // ============================
263
+ // === bundleMultiPageHTML ===
264
+ // ============================
265
+ // Note: If heap exhaustion occurs, you may need to run with --runInBand
266
+ // and potentially comment out tests within this suite to isolate the cause.
267
+ describe('bundleMultiPageHTML()', () => {
268
+ // TODO: This test fails because the actual slug for '///multiple////slashes///page'
269
+ // needs to be determined (e.g., via logging in bundler.ts) and updated below.
270
+ it.skip('should sanitize tricky URLs into slugs', () => {
271
+ // <--- SKIPPED FOR NOW
272
+ const html = bundleMultiPageHTML(trickyPages, mockLogger);
273
+ const $ = cheerio.load(html);
274
+ // console.log('DEBUG Tricky Slugs HTML:\n', $.html()); // Uncomment to inspect HTML if needed
275
+
276
+ expect($('template#page-products-item-1').length).toBe(1);
277
+ expect($('template#page-search-q-test-page-2').length).toBe(1);
278
+ expect($('template#page-path-page').length).toBe(1); // Assumes sanitizeSlug handles spaces/dots
279
+ expect($('template#page-leading-and-trailing').length).toBe(1); // Assumes sanitizeSlug trims
280
+
281
+ // === IMPORTANT ===
282
+ // Verify the actual slug generated by your sanitizeSlug implementation
283
+ // for '///multiple////slashes///page' using logging if needed.
284
+ const multipleSlashesSlug = 'multiple-slashes-page'; // <-- *** REPLACE THIS PLACEHOLDER with actual slug ***
285
+ // =================
286
+
287
+ const multipleSlashesTemplate = $(`template#page-${multipleSlashesSlug}`);
288
+ expect(multipleSlashesTemplate.length).toBe(1); // Check if template with expected ID exists
289
+ expect(multipleSlashesTemplate.html()).toBe('Multiple Slashes'); // Check its content
290
+ });
291
+
292
+ it('should include router script with navigateTo()', () => {
293
+ const pages: PageEntry[] = [{ url: 'index.html', html: '<h1>Hello</h1>' }];
294
+ const html = bundleMultiPageHTML(pages, mockLogger);
295
+ const $ = cheerio.load(html);
296
+ const routerScript = $('#router-script').html();
297
+ expect(routerScript).toContain('function navigateTo');
298
+ expect(routerScript).toContain('navigateTo(initialSlug);');
299
+ });
300
+
301
+ it('should set default page to first valid entry slug', () => {
302
+ const pages: PageEntry[] = [
303
+ { url: 'home.html', html: '<h1>Home</h1>' },
304
+ { url: 'about.html', html: '<h2>About</h2>' },
305
+ ];
306
+ const html = bundleMultiPageHTML(pages, mockLogger);
307
+ // Default should be 'home' as it's the first valid slug and 'index' isn't present
308
+ expect(html).toMatch(
309
+ /const initialSlug = document\.getElementById\('page-' \+ initialHash\) \? initialHash : 'home'/
310
+ );
311
+ });
312
+
313
+ it('should handle index.html or / as default page slug "index"', () => {
314
+ const pages1: PageEntry[] = [
315
+ { url: 'index.html', html: 'Index' },
316
+ { url: 'other', html: 'Other' },
317
+ ];
318
+ const html1 = bundleMultiPageHTML(pages1, mockLogger);
319
+ // Expect 'index' as default when index.html is present
320
+ expect(html1).toMatch(
321
+ /const initialSlug = document\.getElementById\('page-' \+ initialHash\) \? initialHash : 'index'/
322
+ );
323
+
324
+ const pages2: PageEntry[] = [
325
+ { url: '/', html: 'Root Index' },
326
+ { url: 'other', html: 'Other' },
327
+ ];
328
+ const html2 = bundleMultiPageHTML(pages2, mockLogger);
329
+ // Expect 'index' as default when / is present
330
+ expect(html2).toMatch(
331
+ /const initialSlug = document\.getElementById\('page-' \+ initialHash\) \? initialHash : 'index'/
332
+ );
333
+
334
+ const pages3: PageEntry[] = [
335
+ { url: '/other/', html: 'Other' },
336
+ { url: '/index.html', html: 'Index Later' },
337
+ ];
338
+ const html3 = bundleMultiPageHTML(pages3, mockLogger);
339
+ // FIX: Expect 'index' as default because index.html IS present in the list
340
+ expect(html3).toMatch(
341
+ /const initialSlug = document\.getElementById\('page-' \+ initialHash\) \? initialHash : 'index'/
342
+ );
343
+ });
344
+
345
+ it('should throw if input is not an array', () => {
346
+ // @ts-expect-error - Testing invalid input
347
+ expect(() => bundleMultiPageHTML(null, mockLogger)).toThrow(/must be an array/);
348
+ expect(mockLoggerErrorSpy).toHaveBeenCalled();
349
+ });
350
+
351
+ it('should throw if pages array is empty', () => {
352
+ expect(() => bundleMultiPageHTML([], mockLogger)).toThrow(/No valid page entries/);
353
+ expect(mockLoggerErrorSpy).toHaveBeenCalled();
354
+ });
355
+
356
+ it('should throw if all pages are invalid entries', () => {
357
+ // Cast the intentionally invalid array to 'any' to bypass type checking for this test
358
+ expect(() =>
359
+ bundleMultiPageHTML(
360
+ [null, undefined, {}, { url: 'nohtml' }, { html: 'nourl' }] as any, // <-- Cast here
361
+ mockLogger
362
+ )
363
+ ).toThrow(/No valid page entries/);
364
+ expect(mockLoggerErrorSpy).toHaveBeenCalled();
365
+ });
366
+
367
+ it('should log warning and skip invalid entries', () => {
368
+ // Define the 'pages' array explicitly for this test
369
+ const pages: any[] = [
370
+ { url: 'one', html: 'Page 1 Content' }, // Valid (index 0)
371
+ null, // Invalid (index 1)
372
+ { url: 'missing_html' }, // Invalid (index 2, missing html)
373
+ { html: 'missing_url' }, // Invalid (index 3, missing url)
374
+ { url: 'two', html: 'Page 2 Content' }, // Valid (index 4)
375
+ ];
376
+
377
+ const html = bundleMultiPageHTML(pages, mockLogger);
378
+ const $ = cheerio.load(html);
379
+
380
+ // Assertions based on the defined 'pages' array
381
+ expect($('template').length).toBe(2); // Expect 2 valid pages rendered
382
+ expect($('template#page-one').length).toBe(1); // Slug 'one' expected
383
+ expect($('template#page-two').length).toBe(1); // Slug 'two' expected (assuming sanitizeSlug)
384
+
385
+ // Check that warnings were logged for the invalid entries by their original index
386
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
387
+ expect.stringContaining('Skipping invalid page entry at index 1')
388
+ ); // for null
389
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
390
+ expect.stringContaining('Skipping invalid page entry at index 2')
391
+ ); // for { url: 'missing_html' }
392
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
393
+ expect.stringContaining('Skipping invalid page entry at index 3')
394
+ ); // for { html: 'missing_url' }
395
+ expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(3); // Exactly 3 warnings
396
+ });
397
+
398
+ it('should generate nav links and container', () => {
399
+ const pages: PageEntry[] = [
400
+ { url: 'index.html', html: 'Index Content' },
401
+ { url: 'about.html', html: 'About Content' },
402
+ ];
403
+ const html = bundleMultiPageHTML(pages, mockLogger);
404
+ const $ = cheerio.load(html);
405
+ expect($('#main-nav a').length).toBe(2);
406
+ expect($('#page-container').length).toBe(1);
407
+
408
+ // Check specific template ID and content for index.html
409
+ expect($('template#page-index').length).toBe(1);
410
+ expect($('template#page-index').html()).toBe('Index Content');
411
+
412
+ expect($('template#page-about').length).toBe(1); // Check about template
413
+
414
+ // Check nav link for index.html uses slug 'index'
415
+ expect($('#main-nav a[href="#index"]').text()).toBe('index');
416
+ // Check nav link for about.html uses slug 'about'
417
+ expect($('#main-nav a[href="#about"]').text()).toBe('about');
418
+ });
419
+
420
+ it('should generate unique slugs on collision and log warning', () => {
421
+ const pages: PageEntry[] = [
422
+ { url: 'about.html', html: 'A1' }, // slug: about
423
+ { url: '/about', html: 'A2' }, // slug: about -> about-1
424
+ { url: 'about.htm', html: 'A3' }, // slug: about -> about-1 (fail) -> about-2
425
+ ];
426
+ const html = bundleMultiPageHTML(pages, mockLogger);
427
+ const $ = cheerio.load(html);
428
+
429
+ expect($('template#page-about').length).toBe(1);
430
+ expect($('template#page-about-1').length).toBe(1);
431
+ expect($('template#page-about-2').length).toBe(1);
432
+ expect($('#main-nav a[href="#about"]').length).toBe(1);
433
+ expect($('#main-nav a[href="#about-1"]').length).toBe(1);
434
+ expect($('#main-nav a[href="#about-2"]').length).toBe(1);
435
+
436
+ // Match the exact warning messages logged by the implementation
437
+ // Warning 1: /about collides with about, becomes about-1
438
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
439
+ expect.stringContaining(
440
+ 'Slug collision detected for "/about" (intended slug: \'about\'). Using "about-1" instead.'
441
+ )
442
+ );
443
+ // Warning 2: about.htm collides with about, tries about-1
444
+ // Warning 3: about.htm (still) collides with about-1, becomes about-2
445
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
446
+ expect.stringContaining(
447
+ 'Slug collision detected for "about.htm" (intended slug: \'about\'). Using "about-1" instead.'
448
+ )
449
+ );
450
+ expect(mockLoggerWarnSpy).toHaveBeenCalledWith(
451
+ expect.stringContaining(
452
+ 'Slug collision detected for "about.htm" (intended slug: \'about\'). Using "about-2" instead.'
453
+ )
454
+ );
455
+
456
+ // FIX: Expect 3 warnings total based on trace
457
+ expect(mockLoggerWarnSpy).toHaveBeenCalledTimes(3);
458
+ });
459
+ }); // End describe bundleMultiPageHTML
460
+ }); // End describe Core Bundler