html-to-gutenberg 4.2.8 → 4.2.10

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 (60) hide show
  1. package/.env.example +20 -0
  2. package/.eslintrc.json +35 -0
  3. package/.github/workflows/build.yml +26 -0
  4. package/.github/workflows/coverage.yml +26 -0
  5. package/.github/workflows/sync-npm.yml +154 -0
  6. package/.nyc_output/1f0406b8-bb70-495d-8f8a-521fdd81b500.json +1 -0
  7. package/.nyc_output/6390956f-4f8a-4adb-9256-4a1c7e34a52d.json +1 -0
  8. package/.nyc_output/processinfo/1f0406b8-bb70-495d-8f8a-521fdd81b500.json +1 -0
  9. package/.nyc_output/processinfo/6390956f-4f8a-4adb-9256-4a1c7e34a52d.json +1 -0
  10. package/.nyc_output/processinfo/index.json +1 -0
  11. package/@types.d.ts +3 -0
  12. package/coverage/coverage-final.json +4 -0
  13. package/coverage/lcov-report/base.css +224 -0
  14. package/coverage/lcov-report/block-navigation.js +87 -0
  15. package/coverage/lcov-report/prettify.css +1 -0
  16. package/coverage/lcov-report/prettify.js +2 -0
  17. package/coverage/lcov-report/sorter.js +210 -0
  18. package/coverage/lcov.info +198 -0
  19. package/coverage-demo.test.ts +8 -0
  20. package/dist/coverage-demo.test.js +10 -0
  21. package/dist/coverage-demo.test.js.map +1 -0
  22. package/dist/globals.js +24 -0
  23. package/dist/globals.js.map +1 -0
  24. package/dist/index.js +36 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/index.test.js +166 -0
  27. package/dist/index.test.js.map +1 -0
  28. package/dist/package.json +130 -0
  29. package/dist/snapapi-screenshot.test.js +44 -0
  30. package/dist/snapapi-screenshot.test.js.map +1 -0
  31. package/dist/src/coverage-demo.js +7 -0
  32. package/dist/src/coverage-demo.js.map +1 -0
  33. package/dist/src/utils-extra.test.js +137 -0
  34. package/dist/src/utils-extra.test.js.map +1 -0
  35. package/dist/src/utils.test.js +65 -0
  36. package/dist/src/utils.test.js.map +1 -0
  37. package/dist/tsconfig.tsbuildinfo +1 -0
  38. package/dist/utils.js +61 -0
  39. package/dist/utils.js.map +1 -0
  40. package/fetch-page-assets.test.ts +448 -0
  41. package/index.d.ts +173 -0
  42. package/index.js +628 -249
  43. package/index.test.ts +774 -0
  44. package/index.ts +155 -1530
  45. package/package.json +87 -15
  46. package/r2.js +163 -0
  47. package/readme.md +126 -72
  48. package/scripts/patch-fetch-page-assets.mjs +13 -0
  49. package/scripts/sync-from-npm.mjs +115 -0
  50. package/snapapi-screenshot.test.ts +46 -0
  51. package/src/coverage-demo.ts +3 -0
  52. package/src/utils-extra.test.ts +108 -0
  53. package/src/utils.test.ts +36 -0
  54. package/temp-block-test.js +19 -0
  55. package/tsconfig.json +25 -4
  56. package/utils.ts +56 -0
  57. package/vendor/fetch-page-assets/LICENSE.MD +21 -0
  58. package/vendor/fetch-page-assets/README.md +117 -0
  59. package/vendor/fetch-page-assets/index.js +362 -0
  60. package/vendor/fetch-page-assets/package.json +48 -0
package/index.test.ts ADDED
@@ -0,0 +1,774 @@
1
+ import * as utils from './utils.ts';
2
+ import { expect } from 'chai';
3
+ import fs from 'fs';
4
+ import http from 'http';
5
+ import os from 'os';
6
+ import path from 'path';
7
+ import block, {
8
+ buildAssetExtractionOptions,
9
+ createProfiler,
10
+ findSelfClosingJsxEnd,
11
+ formatCategoryLabel,
12
+ getMediaUploadSaveTemplate,
13
+ getSnapApiUrl,
14
+ normalizeBlockOptions,
15
+ replaceMediaUploadComponents,
16
+ replaceRelativeUrls,
17
+ replaceRelativeUrlsInCss,
18
+ replaceRelativeUrlsInCssWithBase,
19
+ replaceRelativeUrlsInHtml,
20
+ replaceRichTextComponents,
21
+ replaceSelfClosingJsxComponent,
22
+ slugifyBlockValue,
23
+ transformBlockFile,
24
+ unwrapBody,
25
+ } from './index.js';
26
+
27
+ const tinyImage = Buffer.from(
28
+ 'ffd8ffe000104a46494600010101006000600000ffdb0043000101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101ffc00011080001000103012200021101031101ffc40014000100000000000000000000000000000008ffc40014100100000000000000000000000000000000ffda0008010100003f00d2cf20ffd9',
29
+ 'hex'
30
+ );
31
+
32
+ const listen = (server: http.Server) => {
33
+ return new Promise<{ baseUrl: string; close: () => Promise<void> }>((resolve, reject) => {
34
+ server.listen(0, '127.0.0.1', () => {
35
+ const address = server.address();
36
+
37
+ if (!address || typeof address === 'string') {
38
+ reject(new Error('Unable to determine server port.'));
39
+ return;
40
+ }
41
+
42
+ resolve({
43
+ baseUrl: `http://127.0.0.1:${address.port}`,
44
+ close: () =>
45
+ new Promise<void>((closeResolve, closeReject) => {
46
+ server.close((error) => {
47
+ if (error) {
48
+ closeReject(error);
49
+ return;
50
+ }
51
+
52
+ closeResolve();
53
+ });
54
+ }),
55
+ });
56
+ });
57
+
58
+ server.on('error', reject);
59
+ });
60
+ };
61
+
62
+ describe('utils.ts functions', () => {
63
+ it('hasTailwindCdnSource returns true for Tailwind CDN', () => {
64
+ expect(utils.hasTailwindCdnSource(['https://cdn.tailwindcss.com'])).to.equal(true);
65
+ expect(utils.hasTailwindCdnSource(['https://example.com'])).to.equal(false);
66
+ });
67
+
68
+ it('replaceSourceUrlVars returns input if no match', () => {
69
+ const str = "var.url+'http://site.com/path'";
70
+ const expected = "${vars.url}/path";
71
+ expect(utils.replaceSourceUrlVars(str, 'http://site.com')).to.equal(expected);
72
+ });
73
+
74
+ it('sanitizeAndReplaceLeadingNumbers replaces leading numbers', () => {
75
+ expect(utils.sanitizeAndReplaceLeadingNumbers('1test')).to.match(/one1test|1test/);
76
+ });
77
+
78
+ it('replaceUnderscoresSpacesAndUppercaseLetters replaces underscores and spaces', () => {
79
+ expect(utils.replaceUnderscoresSpacesAndUppercaseLetters('Test_Name Here')).to.equal('test-name-here');
80
+ });
81
+
82
+ it('convertDashesSpacesAndUppercaseToUnderscoresAndLowercase converts correctly', () => {
83
+ expect(utils.convertDashesSpacesAndUppercaseToUnderscoresAndLowercase('Test-Name Here')).to.equal('test_name_here');
84
+ });
85
+
86
+ it('hasAbsoluteKeyword detects absolute', () => {
87
+ expect(utils.hasAbsoluteKeyword('absolute')).to.equal(true);
88
+ expect(utils.hasAbsoluteKeyword('relative')).to.equal(false);
89
+ });
90
+
91
+ it('generateRandomVariableName returns string with prefix', () => {
92
+ expect(utils.generateRandomVariableName('prefix')).to.match(/^prefix/);
93
+ });
94
+
95
+ // Additional coverage tests
96
+
97
+ describe('hasTailwindCdnSource edge cases', () => {
98
+ it('returns false for empty array', () => {
99
+ expect(utils.hasTailwindCdnSource([])).to.equal(false);
100
+ });
101
+ it('returns true for multiple tailwind URLs', () => {
102
+ expect(utils.hasTailwindCdnSource([
103
+ 'https://cdn.tailwindcss.com',
104
+ 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.0.0'
105
+ ])).to.equal(true);
106
+ });
107
+ it('returns false for malformed URLs', () => {
108
+ expect(utils.hasTailwindCdnSource(['not-a-url'])).to.equal(false);
109
+ });
110
+ });
111
+
112
+ describe('replaceSourceUrlVars edge cases', () => {
113
+ it('replaces matching pattern', () => {
114
+ const str = "var.url+'http://site.com/path'";
115
+ const source = 'http://site.com';
116
+ const expected = "${vars.url}/path";
117
+ expect(utils.replaceSourceUrlVars(str, source)).to.contain(expected);
118
+ });
119
+ it('returns input for empty string', () => {
120
+ expect(utils.replaceSourceUrlVars('', 'http://site.com')).to.equal('');
121
+ });
122
+ it('returns input for null source', () => {
123
+ expect(utils.replaceSourceUrlVars("var.url+'http://site.com/path'", null)).to.equal("var.url+'http://site.com/path'");
124
+ });
125
+ it('replaces multiple matches', () => {
126
+ const str = "var.url+'http://site.com/a' and var.url+'http://site.com/b'";
127
+ const source = 'http://site.com';
128
+ const result = utils.replaceSourceUrlVars(str, source);
129
+ expect(result.match(/\${vars\.url}/g)?.length).to.be.greaterThanOrEqual(2);
130
+ });
131
+ });
132
+
133
+ describe('sanitizeAndReplaceLeadingNumbers edge cases', () => {
134
+ it('returns input for no numbers', () => {
135
+ expect(utils.sanitizeAndReplaceLeadingNumbers('test')).to.equal('test');
136
+ });
137
+ it('handles multiple leading numbers', () => {
138
+ expect(utils.sanitizeAndReplaceLeadingNumbers('123abc')).to.match(/one1two2three3abc|123abc/);
139
+ });
140
+ it('handles only numbers', () => {
141
+ expect(utils.sanitizeAndReplaceLeadingNumbers('123')).to.match(/one1two2three3|123/);
142
+ });
143
+ it('handles special characters', () => {
144
+ expect(utils.sanitizeAndReplaceLeadingNumbers('1_test')).to.match(/one1test|1test/);
145
+ });
146
+ });
147
+
148
+ describe('replaceUnderscoresSpacesAndUppercaseLetters edge cases', () => {
149
+ it('handles only underscores', () => {
150
+ expect(utils.replaceUnderscoresSpacesAndUppercaseLetters('___')).to.equal('---');
151
+ });
152
+ it('handles only spaces', () => {
153
+ expect(utils.replaceUnderscoresSpacesAndUppercaseLetters(' ')).to.equal('---');
154
+ });
155
+ it('handles mixed cases', () => {
156
+ expect(utils.replaceUnderscoresSpacesAndUppercaseLetters('Test_Name Here')).to.equal('test-name-here');
157
+ });
158
+ it('handles empty string', () => {
159
+ expect(utils.replaceUnderscoresSpacesAndUppercaseLetters('')).to.equal('');
160
+ });
161
+ });
162
+
163
+ describe('convertDashesSpacesAndUppercaseToUnderscoresAndLowercase edge cases', () => {
164
+ it('handles only dashes', () => {
165
+ expect(utils.convertDashesSpacesAndUppercaseToUnderscoresAndLowercase('---')).to.equal('___');
166
+ });
167
+ it('handles only spaces', () => {
168
+ expect(utils.convertDashesSpacesAndUppercaseToUnderscoresAndLowercase(' ')).to.equal('___');
169
+ });
170
+ it('handles mixed cases', () => {
171
+ expect(utils.convertDashesSpacesAndUppercaseToUnderscoresAndLowercase('Test-Name Here')).to.equal('test_name_here');
172
+ });
173
+ it('handles empty string', () => {
174
+ expect(utils.convertDashesSpacesAndUppercaseToUnderscoresAndLowercase('')).to.equal('');
175
+ });
176
+ });
177
+
178
+ describe('hasAbsoluteKeyword edge cases', () => {
179
+ it('detects uppercase', () => {
180
+ expect(utils.hasAbsoluteKeyword('ABSOLUTE')).to.equal(true);
181
+ });
182
+ it('returns false for empty string', () => {
183
+ expect(utils.hasAbsoluteKeyword('')).to.equal(false);
184
+ });
185
+ it('returns false for non-string input', () => {
186
+ expect(utils.hasAbsoluteKeyword(null as any)).to.equal(false);
187
+ });
188
+ });
189
+
190
+ describe('generateRandomVariableName edge cases', () => {
191
+ it('returns prefix only for length 0', () => {
192
+ expect(utils.generateRandomVariableName('prefix', 0)).to.equal('prefix');
193
+ });
194
+ it('returns only random part for empty prefix', () => {
195
+ expect(utils.generateRandomVariableName('', 3)).to.match(/^[a-z]{3}$/);
196
+ });
197
+ it('returns correct length', () => {
198
+ const result = utils.generateRandomVariableName('pre', 5);
199
+ expect(result.length).to.equal(8);
200
+ });
201
+ });
202
+ });
203
+
204
+ describe('index.js helper exports', () => {
205
+ it('creates a profiler that only logs when enabled', () => {
206
+ const logs: string[] = [];
207
+ const originalLog = console.log;
208
+ console.log = (message?: any) => logs.push(String(message));
209
+
210
+ try {
211
+ const disabled = createProfiler(false);
212
+ disabled.start('skip');
213
+ disabled.end('skip');
214
+
215
+ const enabled = createProfiler(true);
216
+ enabled.start('profiled');
217
+ enabled.end('profiled');
218
+ } finally {
219
+ console.log = originalLog;
220
+ }
221
+
222
+ expect(logs.length).to.equal(1);
223
+ expect(logs[0]).to.include('[profile] profiled:');
224
+ });
225
+
226
+ it('finds self-closing JSX boundaries and replaces matching components safely', () => {
227
+ const content = `<Wrapper><RichText value={attributes.title} /><MediaUpload render={({ open }) => (<Button title={"/>"} data-template={\`value />\`} onClick={open} />)} /></Wrapper>`;
228
+
229
+ expect(findSelfClosingJsxEnd(content, content.indexOf('<RichText'))).to.be.greaterThan(content.indexOf('<RichText'));
230
+ expect(findSelfClosingJsxEnd('<Broken value={"unterminated"}', 0)).to.equal(-1);
231
+ expect(replaceSelfClosingJsxComponent('plain text', 'RichText', () => 'x')).to.equal('plain text');
232
+ expect(
233
+ replaceSelfClosingJsxComponent('<RichText value={title} />', 'RichText', () => '<RichText.Content value={title} />')
234
+ ).to.equal('<RichText.Content value={title} />');
235
+ expect(
236
+ replaceSelfClosingJsxComponent('<RichText value={"oops"}', 'RichText', () => 'broken')
237
+ ).to.equal('<RichText value={"oops"}');
238
+ });
239
+
240
+ it('replaces media and rich text helper components in save content', () => {
241
+ const mediaTemplate = getMediaUploadSaveTemplate({
242
+ randomUrlVariable: 'imageUrl',
243
+ randomAltVariable: 'imageAlt',
244
+ imgClass: 'hero',
245
+ });
246
+
247
+ expect(getMediaUploadSaveTemplate(undefined)).to.equal('');
248
+ expect(mediaTemplate).to.include('attributes.imageUrl');
249
+ expect(mediaTemplate).to.include('className="hero"');
250
+ expect(
251
+ replaceMediaUploadComponents(
252
+ '<MediaUpload /><MediaUpload />',
253
+ [
254
+ { randomUrlVariable: 'oneUrl', randomAltVariable: 'oneAlt', imgClass: 'one' },
255
+ { randomUrlVariable: 'twoUrl', randomAltVariable: 'twoAlt', imgClass: '' },
256
+ ]
257
+ )
258
+ ).to.include('attributes.twoUrl');
259
+ expect(
260
+ replaceRichTextComponents('<RichText value={attributes.body} /><RichText tagName="span" />')
261
+ ).to.equal('<RichText.Content value={attributes.body} /><RichText tagName="span" />');
262
+ });
263
+
264
+ it('builds asset extraction options and rewrites relative URLs', () => {
265
+ expect(buildAssetExtractionOptions('/tmp/path')).to.deep.equal({
266
+ basePath: '/tmp/path',
267
+ saveFile: false,
268
+ verbose: false,
269
+ maxRetryAttempts: 1,
270
+ retryDelay: 0,
271
+ concurrency: 8,
272
+ uploadToR2: false,
273
+ returnDetails: false,
274
+ jobId: undefined,
275
+ r2Prefix: undefined,
276
+ });
277
+
278
+ expect(
279
+ replaceRelativeUrls('<img src="/image.png"><a href="#hash"></a><form action="submit"></form>', (url) => `https://example.com${url}`)
280
+ ).to.include('src="https://example.com/image.png"');
281
+ expect(
282
+ replaceRelativeUrlsInHtml('<a href="/page">Go</a>', 'https://example.com/base/')
283
+ ).to.equal('<a href="https://example.com/page">Go</a>');
284
+ expect(
285
+ replaceRelativeUrlsInCss(`body{background:url('/bg.png')}a{mask:url(#mask)}div{background:url("https://cdn.example.com/a.png")}`, (url) => `https://example.com${url}`)
286
+ ).to.equal(`body{background:url('https://example.com/bg.png')}a{mask:url(#mask)}div{background:url("https://cdn.example.com/a.png")}`);
287
+ expect(
288
+ replaceRelativeUrlsInCssWithBase(`body{background:url('../bg.png')}`, 'https://example.com/assets/css/site.css')
289
+ ).to.equal(`body{background:url('https://example.com/assets/bg.png')}`);
290
+ expect(
291
+ replaceRelativeUrlsInCssWithBase(`body{background:url("https://cdn.example.com/bg.png")}`, 'https://example.com/assets/css/site.css')
292
+ ).to.equal(`body{background:url("https://cdn.example.com/bg.png")}`);
293
+ });
294
+
295
+ it('normalizes the public block options API and preserves legacy aliases', () => {
296
+ expect(slugifyBlockValue('My Fancy Block!')).to.equal('my-fancy-block');
297
+ expect(formatCategoryLabel('marketing-tools')).to.equal('Marketing Tools');
298
+
299
+ expect(
300
+ normalizeBlockOptions({
301
+ title: 'Marketing Hero',
302
+ slug: 'marketing-hero',
303
+ baseUrl: 'https://example.com',
304
+ namespace: 'myplugins',
305
+ outputPath: '/tmp/out',
306
+ writeFiles: false,
307
+ generatePreviewImage: true,
308
+ registerCategoryIfMissing: true,
309
+ })
310
+ ).to.include({
311
+ title: 'Marketing Hero',
312
+ name: 'Marketing Hero',
313
+ slug: 'marketing-hero',
314
+ namespace: 'myplugins',
315
+ prefix: 'myplugins',
316
+ baseUrl: 'https://example.com',
317
+ source: 'https://example.com',
318
+ outputPath: '/tmp/out',
319
+ basePath: '/tmp/out',
320
+ writeFiles: false,
321
+ shouldSaveFiles: false,
322
+ generatePreviewImage: true,
323
+ generateIconPreview: true,
324
+ registerCategoryIfMissing: true,
325
+ outputMode: 'legacy',
326
+ });
327
+
328
+ expect(
329
+ normalizeBlockOptions({
330
+ name: 'Legacy Name',
331
+ prefix: 'legacy',
332
+ source: 'https://legacy.example.com',
333
+ basePath: '/tmp/legacy',
334
+ shouldSaveFiles: true,
335
+ generateIconPreview: false,
336
+ })
337
+ ).to.include({
338
+ title: 'Legacy Name',
339
+ name: 'Legacy Name',
340
+ namespace: 'legacy',
341
+ prefix: 'legacy',
342
+ baseUrl: 'https://legacy.example.com',
343
+ source: 'https://legacy.example.com',
344
+ outputPath: '/tmp/legacy',
345
+ basePath: '/tmp/legacy',
346
+ writeFiles: true,
347
+ shouldSaveFiles: true,
348
+ generatePreviewImage: false,
349
+ generateIconPreview: false,
350
+ outputMode: 'legacy',
351
+ });
352
+ });
353
+
354
+ it('unwraps HTML/body wrappers, exposes the snap api URL, and handles transform failures', () => {
355
+ const failingValue = {
356
+ replace() {
357
+ throw new Error('no replace');
358
+ },
359
+ };
360
+
361
+ process.env.SNAPAPI_URL = 'http://127.0.0.1:9999/custom-preview';
362
+
363
+ try {
364
+ expect(unwrapBody('<html><body><main>Hi</main></body></html>')).to.equal('<main>Hi</main>');
365
+ expect(unwrapBody(failingValue as any)).to.equal(failingValue);
366
+ expect(getSnapApiUrl()).to.equal('http://127.0.0.1:9999/custom-preview');
367
+ expect(transformBlockFile('const view = <div>Hello</div>;')?.code).to.include('wp.element.createElement');
368
+
369
+ const logs: string[] = [];
370
+ const originalLog = console.log;
371
+ console.log = (message?: any) => logs.push(String(message));
372
+
373
+ try {
374
+ expect(transformBlockFile('const view = <div>').toString()).to.equal('');
375
+ } finally {
376
+ console.log = originalLog;
377
+ }
378
+
379
+ expect(logs.length).to.equal(1);
380
+ } finally {
381
+ delete process.env.SNAPAPI_URL;
382
+ }
383
+ });
384
+ });
385
+
386
+ describe('block generation regressions', () => {
387
+ it('generates a valid block.js for repeated image markup', async function () {
388
+ this.timeout(5000);
389
+
390
+ const html =
391
+ '<!doctype html><html><body>' +
392
+ Array.from({ length: 20 }, () => '<img src="/img/fail.png" /><img src="/img/fail.png" />').join('') +
393
+ '</body></html>';
394
+ const basePath = fs.mkdtempSync(path.join(os.tmpdir(), 'htg-test-images-'));
395
+
396
+ try {
397
+ const result = await block(html, {
398
+ title: 'Test Images',
399
+ namespace: 'wp',
400
+ category: 'common',
401
+ outputPath: basePath,
402
+ writeFiles: false,
403
+ outputMode: 'legacy',
404
+ generatePreviewImage: false,
405
+ jsFiles: [],
406
+ cssFiles: [],
407
+ baseUrl: 'https://example.com',
408
+ });
409
+
410
+ expect(result['block.js']).to.be.a('string').and.to.have.length.greaterThan(1000);
411
+ expect(result['block.js']).to.include('registerBlockType');
412
+ const saveSection = result['block.js'].slice(result['block.js'].indexOf('save(props)'));
413
+ expect(saveSection).to.not.include('MediaUpload');
414
+ } finally {
415
+ fs.rmSync(basePath, { recursive: true, force: true });
416
+ }
417
+ });
418
+
419
+ it('handles a large fixture within a reasonable time budget', async function () {
420
+ this.timeout(5000);
421
+
422
+ const repeated = Array.from(
423
+ { length: 100 },
424
+ (_, i) =>
425
+ `<section class="card"><h2>Item ${i}</h2><p>${'x'.repeat(120)}</p><a href="/item/${i}">Read more</a></section>`
426
+ ).join('');
427
+ const html = `<!doctype html><html><body><main>${repeated}</main></body></html>`;
428
+ const start = process.hrtime.bigint();
429
+
430
+ const result = await block(html, {
431
+ title: 'Large Fixture',
432
+ namespace: 'wp',
433
+ category: 'common',
434
+ outputPath: process.cwd(),
435
+ writeFiles: false,
436
+ outputMode: 'legacy',
437
+ generatePreviewImage: false,
438
+ jsFiles: [],
439
+ cssFiles: [],
440
+ baseUrl: 'https://example.com',
441
+ });
442
+
443
+ const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
444
+
445
+ expect(elapsedMs).to.be.lessThan(2000);
446
+ expect(result['block.js']).to.be.a('string').and.to.have.length.greaterThan(10000);
447
+ });
448
+
449
+ it('covers forms, SVGs, background images, external assets, and preview generation', async function () {
450
+ this.timeout(10000);
451
+
452
+ const warnings: string[] = [];
453
+ const originalWarn = console.warn;
454
+ console.warn = (message?: any) => warnings.push(String(message));
455
+
456
+ const server = http.createServer((req, res) => {
457
+ const url = req.url || '/';
458
+
459
+ if (url === '/remote.css' || url === '/extra.css') {
460
+ res.writeHead(200, { 'Content-Type': 'text/css' });
461
+ res.end(`
462
+ .remote-card { background-image: url('/assets/bg.jpg'); }
463
+ .external-copy { color: #123456; }
464
+ `);
465
+ return;
466
+ }
467
+
468
+ if (url === '/broken.css') {
469
+ res.writeHead(500, { 'Content-Type': 'text/css' });
470
+ res.end('broken');
471
+ return;
472
+ }
473
+
474
+ if (url === '/remote.js') {
475
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
476
+ res.end('window.__remoteScriptLoaded = true;');
477
+ return;
478
+ }
479
+
480
+ if (url === '/broken.js') {
481
+ res.writeHead(500, { 'Content-Type': 'application/javascript' });
482
+ res.end('broken');
483
+ return;
484
+ }
485
+
486
+ if (url === '/assets/plain.jpg' || url === '/assets/bg.jpg') {
487
+ res.writeHead(200, { 'Content-Type': 'image/jpeg' });
488
+ res.end(tinyImage);
489
+ return;
490
+ }
491
+
492
+ if (url === '/snapapi/screenshot') {
493
+ res.writeHead(200, { 'Content-Type': 'image/jpeg' });
494
+ res.end(tinyImage);
495
+ return;
496
+ }
497
+
498
+ if (url === '/snapapi/fail') {
499
+ res.writeHead(500, { 'Content-Type': 'application/json' });
500
+ res.end(JSON.stringify({ error: 'preview failed' }));
501
+ return;
502
+ }
503
+
504
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
505
+ res.end('not found');
506
+ });
507
+
508
+ const { baseUrl, close } = await listen(server);
509
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'htg-complex-'));
510
+ process.env.SNAPAPI_KEY = 'test-key';
511
+ process.env.SNAPAPI_URL = `${baseUrl}/snapapi/screenshot`;
512
+ fs.mkdirSync(path.join(tempRoot, 'coverage-block'), { recursive: true });
513
+
514
+ const html = `
515
+ <!doctype html>
516
+ <html>
517
+ <head>
518
+ <style>
519
+ .inline-card { background-image: url('/assets/bg.jpg'); }
520
+ </style>
521
+ <link rel="stylesheet" href="${baseUrl}/remote.css" />
522
+ <link rel="stylesheet" href="${baseUrl}/broken.css" />
523
+ <script>window.__inlineScriptLoaded = true;</script>
524
+ <script src="${baseUrl}/remote.js"></script>
525
+ <script src="${baseUrl}/broken.js"></script>
526
+ </head>
527
+ <body>
528
+ <!-- remove me -->
529
+ <div class="items-center inline-card remote-card">
530
+ <span class="link-wrapper"><a href="/contact" data-target="/track">Contact us</a></span>
531
+ </div>
532
+ <form action="/submit" method="post">
533
+ <input name="email" value="hello@example.com" />
534
+ <textarea name="message">Hello there</textarea>
535
+ </form>
536
+ <div class="absolute-parent" style="position:absolute">
537
+ <img class="hero absolute-image" src="/assets/bg.jpg" alt="Hero background" />
538
+ </div>
539
+ <div class="svg-holder">
540
+ <svg viewBox="0 0 10 10"><path fill-rule="evenodd" d="M0 0h10v10z"></path></svg>
541
+ </div>
542
+ <img src="/assets/plain.jpg" alt="Plain image" />
543
+ <p style="margin-top: 10px; padding-left: 5px;">Hello <strong>world</strong></p>
544
+ </body>
545
+ </html>
546
+ `;
547
+
548
+ try {
549
+ const result = await block(html, {
550
+ title: 'Coverage Block',
551
+ namespace: 'My Prefix',
552
+ category: 'widgets',
553
+ outputPath: tempRoot,
554
+ writeFiles: true,
555
+ outputMode: 'legacy',
556
+ generatePreviewImage: true,
557
+ registerCategoryIfMissing: true,
558
+ jsFiles: ['https://cdn.tailwindcss.com', `${baseUrl}/broken.js`],
559
+ cssFiles: [`${baseUrl}/extra.css`, `${baseUrl}/broken.css`],
560
+ baseUrl: `${baseUrl}/pages/demo`,
561
+ });
562
+
563
+ const outputDir = path.join(tempRoot, 'coverage-block');
564
+ const saveSection = result['block.js'].slice(result['block.js'].indexOf('save(props)'));
565
+ const savedScripts = fs.readFileSync(path.join(outputDir, 'scripts.js'), 'utf8');
566
+
567
+ expect(result['block.js']).to.include('registerBlockType');
568
+ expect(result['block.js']).to.include('SVG Markup');
569
+ expect(result['block.js']).to.include('Background Image');
570
+ expect(result['block.js']).to.include('Form Settings');
571
+ expect(result['block.js']).to.include('Email Settings');
572
+ expect(result['block.js']).to.include('Hidden Fields');
573
+ expect(result['block.js']).to.include('RichText.Content');
574
+ expect(result['block.js']).to.include('ensureBlockCategory');
575
+ expect(result['block.js']).to.match(/slug:\s*["']widgets["']/);
576
+ expect(saveSection).to.not.include('MediaUpload');
577
+ expect(result['index.php']).to.include('wp_mail');
578
+ expect(result['index.php']).to.include(`${baseUrl}/extra.css`);
579
+ expect(result['scripts.js']).to.include('window.__inlineScriptLoaded = true;');
580
+ expect(savedScripts).to.include('send_email_');
581
+ expect(result['style.css']).to.not.include('all: revert-layer');
582
+ expect(fs.existsSync(path.join(outputDir, 'style.css'))).to.equal(true);
583
+ expect(fs.existsSync(path.join(outputDir, 'editor.css'))).to.equal(true);
584
+ expect(fs.existsSync(path.join(outputDir, 'scripts.js'))).to.equal(true);
585
+ expect(fs.existsSync(path.join(outputDir, 'index.php'))).to.equal(true);
586
+ expect(fs.existsSync(path.join(outputDir, 'block.js'))).to.equal(true);
587
+ expect(fs.existsSync(path.join(outputDir, 'preview.jpeg'))).to.equal(true);
588
+ expect(warnings.some((message) => message.includes('Failed to fetch:'))).to.equal(true);
589
+ expect(warnings.some((message) => message.includes('Failed to fetch script:'))).to.equal(true);
590
+ } finally {
591
+ delete process.env.SNAPAPI_KEY;
592
+ delete process.env.SNAPAPI_URL;
593
+ console.warn = originalWarn;
594
+ await close();
595
+ fs.rmSync(tempRoot, { recursive: true, force: true });
596
+ }
597
+ });
598
+
599
+ it('handles preview generation failures without aborting block generation', async function () {
600
+ this.timeout(8000);
601
+
602
+ const server = http.createServer((req, res) => {
603
+ if (req.url === '/snapapi/fail') {
604
+ res.writeHead(500, { 'Content-Type': 'application/json' });
605
+ res.end(JSON.stringify({ error: 'preview failed' }));
606
+ return;
607
+ }
608
+
609
+ res.writeHead(200, { 'Content-Type': 'image/jpeg' });
610
+ res.end(tinyImage);
611
+ });
612
+
613
+ const { baseUrl, close } = await listen(server);
614
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'htg-preview-fail-'));
615
+ const logs: string[] = [];
616
+ const originalLog = console.log;
617
+ console.log = (message?: any) => logs.push(String(message));
618
+
619
+ try {
620
+ process.env.SNAPAPI_KEY = '';
621
+
622
+ const resultWithoutKey = await block('<html><body><h1>No Key</h1></body></html>', {
623
+ title: 'Preview Missing Key',
624
+ namespace: 'wp',
625
+ category: 'common',
626
+ outputPath: tempRoot,
627
+ writeFiles: false,
628
+ outputMode: 'legacy',
629
+ generatePreviewImage: true,
630
+ jsFiles: [],
631
+ cssFiles: [],
632
+ baseUrl: `${baseUrl}/page`,
633
+ });
634
+
635
+ process.env.SNAPAPI_KEY = 'test-key';
636
+ process.env.SNAPAPI_URL = `${baseUrl}/snapapi/fail`;
637
+ fs.mkdirSync(path.join(tempRoot, 'preview-http-failure'), { recursive: true });
638
+
639
+ const resultWithHttpFailure = await block('<html><body><h1>Bad Preview</h1></body></html>', {
640
+ title: 'Preview HTTP Failure',
641
+ namespace: 'wp',
642
+ category: 'common',
643
+ outputPath: tempRoot,
644
+ writeFiles: true,
645
+ outputMode: 'legacy',
646
+ generatePreviewImage: true,
647
+ jsFiles: [],
648
+ cssFiles: [],
649
+ baseUrl: `${baseUrl}/page`,
650
+ });
651
+
652
+ expect(resultWithoutKey['block.js']).to.include('registerBlockType');
653
+ expect(resultWithHttpFailure['block.js']).to.include('registerBlockType');
654
+ expect(logs.some((message) => message.includes('There was an error generating preview with SnapAPI.'))).to.equal(true);
655
+ expect(logs.some((message) => message.includes('SNAPAPI_KEY is not set'))).to.equal(true);
656
+ expect(logs.some((message) => message.includes('SnapAPI error: 500'))).to.equal(true);
657
+ expect(
658
+ fs.existsSync(path.join(tempRoot, 'preview-http-failure', 'preview.jpeg'))
659
+ ).to.equal(false);
660
+ } finally {
661
+ delete process.env.SNAPAPI_KEY;
662
+ delete process.env.SNAPAPI_URL;
663
+ console.log = originalLog;
664
+ await close();
665
+ fs.rmSync(tempRoot, { recursive: true, force: true });
666
+ }
667
+ });
668
+
669
+ it('covers numeric naming, source-prefixed image URLs, invalid absolute image URLs, and empty content', async function () {
670
+ this.timeout(8000);
671
+
672
+ const server = http.createServer((req, res) => {
673
+ if (req.url === '/assets/plain.jpg') {
674
+ res.writeHead(200, { 'Content-Type': 'image/jpeg' });
675
+ res.end(tinyImage);
676
+ return;
677
+ }
678
+
679
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
680
+ res.end('not found');
681
+ });
682
+
683
+ const { baseUrl, close } = await listen(server);
684
+
685
+ try {
686
+ const edgeResult = await block(
687
+ `<html><body><img src="${baseUrl}/assets/plain.jpg" alt="Absolute" /><img src="http://[" alt="Broken" /></body></html>`,
688
+ {
689
+ title: '123 Edge Block',
690
+ namespace: '9 Prefix',
691
+ category: 'common',
692
+ outputPath: process.cwd(),
693
+ writeFiles: false,
694
+ outputMode: 'legacy',
695
+ generatePreviewImage: false,
696
+ jsFiles: [],
697
+ cssFiles: [],
698
+ baseUrl,
699
+ }
700
+ );
701
+
702
+ const emptyResult = await block('', {
703
+ title: 'Empty Body',
704
+ namespace: '',
705
+ category: 'common',
706
+ outputPath: process.cwd(),
707
+ writeFiles: false,
708
+ outputMode: 'legacy',
709
+ generatePreviewImage: false,
710
+ jsFiles: [],
711
+ cssFiles: [],
712
+ baseUrl: null,
713
+ });
714
+
715
+ expect(edgeResult).to.have.property('block.js');
716
+ expect(emptyResult).to.have.property('block.js');
717
+ } finally {
718
+ await close();
719
+ }
720
+ });
721
+
722
+ it('returns an R2-backed job manifest without writing files to disk', async function () {
723
+ this.timeout(8000);
724
+
725
+ const server = http.createServer((req, res) => {
726
+ if (req.url === '/asset.png') {
727
+ res.writeHead(200, { 'Content-Type': 'image/png' });
728
+ res.end(Buffer.from('89504e470d0a1a0a', 'hex'));
729
+ return;
730
+ }
731
+
732
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
733
+ res.end('not found');
734
+ });
735
+
736
+ const { baseUrl, close } = await listen(server);
737
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'htg-job-'));
738
+ process.env.HTG_R2_MOCK = '1';
739
+ process.env.CLOUDFLARE_R2_PUBLIC_BASE_URL = 'https://storage.example.com';
740
+
741
+ try {
742
+ const result = await block(`<html><body><img src="${baseUrl}/asset.png" alt="Asset" /><p>Hello</p></body></html>`, {
743
+ title: 'JSON Job',
744
+ slug: 'conv-job',
745
+ namespace: 'wp',
746
+ category: 'common',
747
+ outputPath: tempRoot,
748
+ writeFiles: false,
749
+ outputMode: 'job',
750
+ uploadToR2: true,
751
+ generatePreviewImage: false,
752
+ jsFiles: [],
753
+ cssFiles: [],
754
+ baseUrl,
755
+ jobId: 'conv_123',
756
+ });
757
+
758
+ expect(result.jobId).to.equal('conv_123');
759
+ expect(result.status).to.equal('completed');
760
+ expect(result.output.files.some((file) => file.name === 'block.js' && file.kind === 'source')).to.equal(true);
761
+ expect(result.output.files.some((file) => file.name === 'asset.png' && file.kind === 'asset')).to.equal(true);
762
+ expect(result.output.bundle.name).to.equal('output.zip');
763
+ expect(result.output.bundle.path).to.equal('/generated/conv_123/output.zip');
764
+ expect(result.output.bundle.url).to.equal('https://storage.example.com/generated/conv_123/output.zip');
765
+ expect(result.output.bundle.zipUrl).to.equal('https://storage.example.com/generated/conv_123/output.zip');
766
+ expect(fs.existsSync(path.join(tempRoot, 'conv-job', 'block.js'))).to.equal(false);
767
+ } finally {
768
+ delete process.env.HTG_R2_MOCK;
769
+ delete process.env.CLOUDFLARE_R2_PUBLIC_BASE_URL;
770
+ await close();
771
+ fs.rmSync(tempRoot, { recursive: true, force: true });
772
+ }
773
+ });
774
+ });