html-to-gutenberg 4.2.9 → 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.
package/index.test.ts CHANGED
@@ -1,6 +1,63 @@
1
- import * as utils from './utils';
1
+ import * as utils from './utils.ts';
2
2
  import { expect } from 'chai';
3
- // Removed imports from index and globals to avoid ESM parsing errors
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
+ };
4
61
 
5
62
  describe('utils.ts functions', () => {
6
63
  it('hasTailwindCdnSource returns true for Tailwind CDN', () => {
@@ -125,8 +182,8 @@ describe('utils.ts functions', () => {
125
182
  it('returns false for empty string', () => {
126
183
  expect(utils.hasAbsoluteKeyword('')).to.equal(false);
127
184
  });
128
- it('returns false for empty string', () => {
129
- expect(utils.hasAbsoluteKeyword('')).to.equal(false);
185
+ it('returns false for non-string input', () => {
186
+ expect(utils.hasAbsoluteKeyword(null as any)).to.equal(false);
130
187
  });
131
188
  });
132
189
 
@@ -143,3 +200,575 @@ describe('utils.ts functions', () => {
143
200
  });
144
201
  });
145
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
+ });