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.js CHANGED
@@ -9,6 +9,13 @@ import dotenv from 'dotenv';
9
9
  import { createRequire } from 'module';
10
10
  import convert from 'node-html-to-jsx';
11
11
  import path from 'path';
12
+ import {
13
+ createFileRecord,
14
+ createJobId,
15
+ inferContentType,
16
+ uploadBufferToR2,
17
+ zipEntriesToBuffer,
18
+ } from './r2.js';
12
19
 
13
20
  import {
14
21
  imports,
@@ -19,32 +26,411 @@ import {
19
26
  const require = createRequire(import.meta.url);
20
27
  const { version } = require('./package.json');
21
28
 
29
+ export const createProfiler = (enabled) => {
30
+ const marks = new Map();
31
+
32
+ return {
33
+ start(label) {
34
+ if (enabled) {
35
+ marks.set(label, process.hrtime.bigint());
36
+ }
37
+ },
38
+ end(label) {
39
+ if (enabled && marks.has(label)) {
40
+ const start = marks.get(label);
41
+ const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
42
+ console.log(`[profile] ${label}: ${elapsedMs.toFixed(2)}ms`);
43
+ marks.delete(label);
44
+ }
45
+ },
46
+ };
47
+ };
48
+
49
+ export const findSelfClosingJsxEnd = (content, startIndex) => {
50
+ let inSingleQuote = false;
51
+ let inDoubleQuote = false;
52
+ let inTemplateQuote = false;
53
+ let braceDepth = 0;
54
+ let parenDepth = 0;
55
+
56
+ for (let i = startIndex; i < content.length - 1; i++) {
57
+ const char = content[i];
58
+ const next = content[i + 1];
59
+ const prev = i > 0 ? content[i - 1] : '';
60
+ const escaped = prev === '\\';
61
+
62
+ if (!escaped) {
63
+ if (!inDoubleQuote && !inTemplateQuote && char === '\'') {
64
+ inSingleQuote = !inSingleQuote;
65
+ continue;
66
+ }
67
+ if (!inSingleQuote && !inTemplateQuote && char === '"') {
68
+ inDoubleQuote = !inDoubleQuote;
69
+ continue;
70
+ }
71
+ if (!inSingleQuote && !inDoubleQuote && char === '`') {
72
+ inTemplateQuote = !inTemplateQuote;
73
+ continue;
74
+ }
75
+ }
76
+
77
+ if (inSingleQuote || inDoubleQuote || inTemplateQuote) {
78
+ continue;
79
+ }
80
+
81
+ if (char === '{') {
82
+ braceDepth++;
83
+ continue;
84
+ }
85
+
86
+ if (char === '}') {
87
+ braceDepth = Math.max(0, braceDepth - 1);
88
+ continue;
89
+ }
90
+
91
+ if (char === '(') {
92
+ parenDepth++;
93
+ continue;
94
+ }
95
+
96
+ if (char === ')') {
97
+ parenDepth = Math.max(0, parenDepth - 1);
98
+ continue;
99
+ }
100
+
101
+ if (char === '/' && next === '>' && braceDepth === 0 && parenDepth === 0) {
102
+ return i + 2;
103
+ }
104
+ }
105
+
106
+ return -1;
107
+ };
108
+
109
+ export const replaceSelfClosingJsxComponent = (content, componentName, replacer) => {
110
+ const openTag = `<${componentName}`;
111
+
112
+ if (!content.includes(openTag)) {
113
+ return content;
114
+ }
115
+
116
+ let cursor = 0;
117
+ let result = '';
118
+
119
+ while (cursor < content.length) {
120
+ const start = content.indexOf(openTag, cursor);
121
+
122
+ if (start === -1) {
123
+ result += content.slice(cursor);
124
+ break;
125
+ }
126
+
127
+ result += content.slice(cursor, start);
128
+ const end = findSelfClosingJsxEnd(content, start);
129
+
130
+ if (end === -1) {
131
+ result += content.slice(start);
132
+ break;
133
+ }
134
+
135
+ result += replacer(content.slice(start, end));
136
+ cursor = end;
137
+ }
138
+
139
+ return result;
140
+ };
141
+
142
+ export const getMediaUploadSaveTemplate = (image) => {
143
+ if (!image) {
144
+ return '';
145
+ }
146
+
147
+ const { randomUrlVariable, randomAltVariable, imgClass } = image;
148
+ const classNameAttr = imgClass ? ` className="${imgClass}"` : '';
149
+
150
+ return `<img src={attributes.${randomUrlVariable}} alt={attributes.${randomAltVariable}}${classNameAttr} />`;
151
+ };
152
+
153
+ export const replaceMediaUploadComponents = (content, imageRegistry) => {
154
+ let imageIndex = 0;
155
+
156
+ return replaceSelfClosingJsxComponent(content, 'MediaUpload', () => {
157
+ const template = getMediaUploadSaveTemplate(imageRegistry[imageIndex]);
158
+ imageIndex++;
159
+ return template;
160
+ });
161
+ };
162
+
163
+ export const replaceRichTextComponents = (content) => {
164
+ return replaceSelfClosingJsxComponent(content, 'RichText', (componentSource) => {
165
+ const valueMatch = componentSource.match(/\bvalue=\{([^}]+)\}/);
166
+
167
+ if (!valueMatch) {
168
+ return componentSource;
169
+ }
170
+
171
+ return `<RichText.Content value={${valueMatch[1]}} />`;
172
+ });
173
+ };
174
+
175
+ export const buildAssetExtractionOptions = (basePath, options = {}) => ({
176
+ basePath,
177
+ saveFile: false,
178
+ verbose: false,
179
+ maxRetryAttempts: 1,
180
+ retryDelay: 0,
181
+ concurrency: 8,
182
+ uploadToR2: options.uploadToR2 || false,
183
+ returnDetails: options.returnDetails || false,
184
+ jobId: options.jobId,
185
+ r2Prefix: options.r2Prefix,
186
+ });
187
+
188
+ export const slugifyBlockValue = (value = '') => {
189
+ return String(value)
190
+ .replace(/\W|_/g, '-')
191
+ .replace(/-+/g, '-')
192
+ .replace(/^-|-$/g, '')
193
+ .toLowerCase();
194
+ };
195
+
196
+ export const formatCategoryLabel = (category = '') => {
197
+ return String(category)
198
+ .split(/[-_\s]+/)
199
+ .filter(Boolean)
200
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
201
+ .join(' ');
202
+ };
203
+
204
+ export const normalizeBlockOptions = (options = {}) => {
205
+ const hasOwn = (key) => Object.prototype.hasOwnProperty.call(options, key);
206
+ const title = options.title ?? options.name ?? 'My block';
207
+ const slug = slugifyBlockValue(options.slug ?? title) || 'my-block';
208
+ const namespace = options.namespace ?? options.prefix ?? 'wp';
209
+ const baseUrl = options.baseUrl ?? options.source ?? null;
210
+ const outputPath = options.outputPath ?? options.basePath ?? process.cwd();
211
+ const hasExplicitLocalOutputPreference =
212
+ hasOwn('writeFiles') ||
213
+ hasOwn('outputPath') ||
214
+ hasOwn('shouldSaveFiles') ||
215
+ hasOwn('basePath');
216
+ const hasExplicitJobPreference = hasOwn('uploadToR2') || hasOwn('jobId');
217
+ const outputMode =
218
+ options.outputMode ??
219
+ (hasExplicitJobPreference ? 'job' : hasExplicitLocalOutputPreference ? 'legacy' : 'job');
220
+ const writeFiles = options.writeFiles ?? options.shouldSaveFiles ?? (outputMode === 'legacy');
221
+ const generatePreviewImage =
222
+ options.generatePreviewImage ?? options.generateIconPreview ?? false;
223
+
224
+ return {
225
+ ...options,
226
+ title,
227
+ name: title,
228
+ slug,
229
+ namespace,
230
+ prefix: namespace,
231
+ baseUrl,
232
+ source: baseUrl,
233
+ category: options.category ?? 'common',
234
+ registerCategoryIfMissing: options.registerCategoryIfMissing ?? false,
235
+ outputPath,
236
+ basePath: outputPath,
237
+ writeFiles,
238
+ shouldSaveFiles: writeFiles,
239
+ generatePreviewImage,
240
+ generateIconPreview: generatePreviewImage,
241
+ jsFiles: options.jsFiles ?? [],
242
+ cssFiles: options.cssFiles ?? [],
243
+ outputMode,
244
+ uploadToR2: options.uploadToR2 ?? outputMode === 'job',
245
+ jobId: options.jobId,
246
+ };
247
+ };
248
+
249
+ export const replaceRelativeUrls = (html, replacer) => {
250
+ const urlAttributes = [
251
+ 'src', 'href', 'action', 'srcset', 'poster', 'data', 'formaction'
252
+ ];
253
+
254
+ const regex = new RegExp(
255
+ `\\b(${urlAttributes.join('|')}|data-[a-zA-Z0-9_-]+)\\s*=\\s*(['"])(?!https?:|//|mailto:|tel:|#)([^'"]+)\\2`,
256
+ 'gi'
257
+ );
258
+
259
+ return html.replace(regex, (_match, attr, quote, url) => {
260
+ const newUrl = replacer(url);
261
+ return `${attr}=${quote}${newUrl}${quote}`;
262
+ });
263
+ };
264
+
265
+ export const replaceRelativeUrlsInCss = (css, replacer) => {
266
+ const regex = /url\(\s*(['"]?)(.+?)\1\s*\)/gi;
267
+
268
+ return css.replace(regex, (match, quote, url) => {
269
+ if (/^(https?:|\/\/|data:|mailto:|tel:|#)/.test(url.trim())) {
270
+ return match;
271
+ }
272
+
273
+ const newUrl = replacer(url);
274
+ return `url(${quote}${newUrl}${quote})`;
275
+ });
276
+ };
277
+
278
+ export const replaceRelativeUrlsInHtml = (html, baseUrl) => {
279
+ return replaceRelativeUrls(html, (url) => {
280
+ return new URL(url, baseUrl).href;
281
+ });
282
+ };
283
+
284
+ export const replaceRelativeUrlsInCssWithBase = (css, cssFileUrl) => {
285
+ return replaceRelativeUrlsInCss(css, (url) => {
286
+ return new URL(url, cssFileUrl).href;
287
+ });
288
+ };
289
+
290
+ export const unwrapBody = (code) => {
291
+ try {
292
+ return code.replace(/<\/?(html|body)[^>]*>/gi, '');
293
+ } catch (_error) {
294
+ return code;
295
+ }
296
+ };
297
+
298
+ export const transformBlockFile = (blockCode) => {
299
+ let transformedCode = '';
300
+
301
+ try {
302
+ transformedCode = babel.transformSync(blockCode, {
303
+ presets: [[presetReact, { pragma: 'wp.element.createElement' }]],
304
+ filename: 'block.js'
305
+ });
306
+ } catch (error) {
307
+ console.log(error);
308
+ }
309
+
310
+ return transformedCode;
311
+ };
312
+
313
+ export const getSnapApiUrl = () => {
314
+ return process.env.SNAPAPI_URL || 'https://api.snapapi.pics/v1/screenshot';
315
+ };
316
+
317
+ const uploadJobPackage = async ({ jobId, generatedFiles, assetFiles, previewArtifact }) => {
318
+ const allFiles = [];
319
+ const zipEntries = [];
320
+ let sourceIndex = 1;
321
+ let assetIndex = 1;
322
+
323
+ for (const [name, contents] of Object.entries(generatedFiles)) {
324
+ const body = typeof contents === 'string' ? Buffer.from(contents, 'utf8') : Buffer.from(contents || '');
325
+ const storageKey = `generated/${jobId}/${name}`;
326
+ const uploadResult = await uploadBufferToR2({
327
+ storageKey,
328
+ body,
329
+ contentType: inferContentType(name),
330
+ });
331
+
332
+ allFiles.push(
333
+ createFileRecord({
334
+ id: `file_${sourceIndex++}`,
335
+ name,
336
+ kind: 'source',
337
+ storageKey: uploadResult.storageKey,
338
+ size: uploadResult.size,
339
+ type: uploadResult.type,
340
+ url: uploadResult.url,
341
+ })
342
+ );
343
+
344
+ zipEntries.push({ name, body });
345
+ }
346
+
347
+ for (const assetFile of assetFiles) {
348
+ allFiles.push({
349
+ id: `file_${sourceIndex + assetIndex - 1}`,
350
+ name: assetFile.name,
351
+ type: assetFile.type,
352
+ size: assetFile.size,
353
+ path: assetFile.path,
354
+ url: assetFile.url,
355
+ kind: assetFile.kind || 'asset',
356
+ });
357
+
358
+ if (assetFile.buffer) {
359
+ zipEntries.push({
360
+ name: path.posix.relative(`generated/${jobId}`, assetFile.path.replace(/^\//, '')),
361
+ body: assetFile.buffer,
362
+ });
363
+ }
364
+
365
+ assetIndex++;
366
+ }
367
+
368
+ if (previewArtifact) {
369
+ allFiles.push(previewArtifact.file);
370
+ zipEntries.push({
371
+ name: previewArtifact.file.name,
372
+ body: previewArtifact.buffer,
373
+ });
374
+ }
375
+
376
+ const zipBuffer = await zipEntriesToBuffer(zipEntries);
377
+ const bundleUpload = await uploadBufferToR2({
378
+ storageKey: `generated/${jobId}/output.zip`,
379
+ body: zipBuffer,
380
+ contentType: 'application/zip',
381
+ });
382
+ const bundleFile = createFileRecord({
383
+ id: 'file_bundle',
384
+ name: 'output.zip',
385
+ kind: 'bundle',
386
+ storageKey: bundleUpload.storageKey,
387
+ size: bundleUpload.size,
388
+ type: bundleUpload.type,
389
+ url: bundleUpload.url,
390
+ });
391
+
392
+ return {
393
+ jobId,
394
+ status: 'completed',
395
+ output: {
396
+ files: allFiles,
397
+ bundle: {
398
+ id: bundleFile.id,
399
+ name: bundleFile.name,
400
+ type: bundleFile.type,
401
+ size: bundleFile.size,
402
+ path: bundleFile.path,
403
+ url: bundleFile.url,
404
+ zipUrl: bundleUpload.url,
405
+ },
406
+ },
407
+ };
408
+ };
409
+
22
410
  const block = async (
23
411
  htmlContent,
24
- options = {
25
- name: 'My block',
26
- prefix: 'wp',
27
- category: 'common',
28
- basePath: process.cwd(),
29
- shouldSaveFiles: true,
30
- generateIconPreview: false,
31
- jsFiles: [],
32
- cssFiles: [],
33
- source: null,
34
- }
412
+ rawOptions = {}
35
413
  ) => {
414
+ const options = normalizeBlockOptions(rawOptions);
36
415
  const panels = [];
37
416
  const styles = [];
38
417
  const scripts = [];
39
418
  const attributes = {};
40
419
  const formVars = {};
420
+ const extractedAssets = [];
421
+ images.length = 0;
41
422
 
42
- const { name, prefix, source } = options;
423
+ const { name, prefix, source, slug, registerCategoryIfMissing } = options;
424
+ const outputMode = options.outputMode;
425
+ const useR2Storage = options.uploadToR2;
426
+ const jobId = options.jobId || createJobId();
43
427
 
44
428
  let js = '';
45
429
  let css = '';
46
430
  let phpEmailData = '';
47
431
  let emailTemplate = '';
432
+ let previewArtifact = null;
433
+ const profiler = createProfiler(process.env.HTG_PROFILE === '1');
48
434
 
49
435
  function hasTailwindCdnSource(jsFiles) {
50
436
  const tailwindCdnRegex = /https:\/\/(cdn\.tailwindcss\.com(\?[^"'\s]*)?|cdn\.jsdelivr\.net\/npm\/@tailwindcss\/browser@4(\.\d+){0,2})/;
@@ -78,9 +464,7 @@ const block = async (
78
464
  .replace(/^[^a-z]+/, '');
79
465
  }
80
466
 
81
- const replaceUnderscoresSpacesAndUppercaseLetters = (name = '') => {
82
- return name.replace(new RegExp(/\W|_/, 'g'), '-').toLowerCase();
83
- };
467
+ const replaceUnderscoresSpacesAndUppercaseLetters = (name = '') => slugifyBlockValue(name);
84
468
 
85
469
  const saveFile = (fileName, contents, options) => {
86
470
  try {
@@ -94,50 +478,6 @@ const block = async (
94
478
  }
95
479
  };
96
480
 
97
- function replaceRelativeUrls(html, replacer) {
98
- const urlAttributes = [
99
- 'src', 'href', 'action', 'srcset', 'poster', 'data', 'formaction'
100
- ];
101
-
102
- const regex = new RegExp(
103
- `\\b(${urlAttributes.join('|')}|data-[a-zA-Z0-9_-]+)\\s*=\\s*(['"])(?!https?:|//|mailto:|tel:|#)([^'"]+)\\2`,
104
- 'gi'
105
- );
106
-
107
- return html.replace(regex, (_match, attr, quote, url) => {
108
- const newUrl = replacer(url);
109
- return `${attr}=${quote}${newUrl}${quote}`;
110
- });
111
- }
112
-
113
-
114
- function replaceRelativeUrlsInCss(css, replacer) {
115
- const regex = /url\(\s*(['"]?)(.+?)\1\s*\)/gi;
116
-
117
- return css.replace(regex, (match, quote, url) => {
118
- if (/^(https?:|\/\/|data:|mailto:|tel:|#)/.test(url.trim())) {
119
- return match;
120
- }
121
- const newUrl = replacer(url);
122
- return `url(${quote}${newUrl}${quote})`;
123
- });
124
- }
125
-
126
- function replaceRelativeUrlsInHtml(html, baseUrl) {
127
- return replaceRelativeUrls(html, (url) => {
128
- return new URL(url, baseUrl).href;
129
- });
130
- }
131
-
132
- function replaceRelativeUrlsInCssWithBase(css, cssFileUrl) {
133
- return replaceRelativeUrlsInCss(css, (url) => {
134
- if (/^(https?:|\/\/|data:|mailto:|tel:|#)/.test(url.trim())) {
135
- return url;
136
- }
137
- return new URL(url, cssFileUrl).href;
138
- });
139
- }
140
-
141
481
  const parseRequirements = async (files, options) => {
142
482
  const { source } = options;
143
483
  let output = '';
@@ -173,9 +513,10 @@ const block = async (
173
513
  return '';
174
514
  };
175
515
 
176
- const newName = replaceUnderscoresSpacesAndUppercaseLetters(name);
177
- const blockName = `${sanitizeAndReplaceLeadingNumbers(replaceUnderscoresSpacesAndUppercaseLetters(prefix))}/${sanitizeAndReplaceLeadingNumbers(replaceUnderscoresSpacesAndUppercaseLetters(name))}`;
178
- const blockNameHandle = `${prefix}-${newName}`;
516
+ const newName = slug;
517
+ const normalizedNamespace = replaceUnderscoresSpacesAndUppercaseLetters(prefix);
518
+ const blockName = `${sanitizeAndReplaceLeadingNumbers(normalizedNamespace)}/${sanitizeAndReplaceLeadingNumbers(newName)}`;
519
+ const blockNameHandle = `${normalizedNamespace}-${newName}`;
179
520
 
180
521
  const getPhp = (options) => {
181
522
  const { name, prefix, jsFiles, cssFiles } = options;
@@ -386,40 +727,6 @@ const block = async (
386
727
  `;
387
728
  };
388
729
 
389
- function preprocessSvgAttributes(code) {
390
- return code.replace(/(<svg[\s\S]*?>[\s\S]*?<\/svg>)/gi, (svgBlock) => {
391
- let processed = svgBlock.replace(/([a-zA-Z0-9]+)-([a-zA-Z0-9]+)=/g, (match, p1, p2) => {
392
- const camel = p1 + p2.charAt(0).toUpperCase() + p2.slice(1);
393
- return camel + '=';
394
- });
395
- return processed;
396
- });
397
- }
398
-
399
- function unwrapBody(code) {
400
- try {
401
- return code.replace(/<\/?(html|body)[^>]*>/gi, '');
402
- } catch (e) {
403
- return code;
404
- }
405
- }
406
-
407
- function transformBlockFile(blockCode) {
408
- let test = '';
409
-
410
- try {
411
- test = babel.transformSync(blockCode, {
412
- presets: [[presetReact, { pragma: 'wp.element.createElement' }]],
413
- filename: 'block.js'
414
- });
415
- } catch (error) {
416
- console.log(error);
417
-
418
- }
419
-
420
- return test;
421
- }
422
-
423
730
  const saveFiles = async (options) => {
424
731
  const { cssFiles = [], jsFiles = [], shouldSaveFiles, name, prefix } = options;
425
732
  const tailwindRegex = /(class|className)\s*=\s*["'][^"']*\b(items-center|justify-center|gap-\d+|rounded(-[a-z]+)?|text-[a-z]+-\d{3}|bg-[a-z]+-\d{3}|w-(full|screen)|h-(full|screen)|max-w-[\w\[\]-]+|p-\d+|m-\d+)\b[^"']*["']/i;
@@ -448,8 +755,9 @@ const block = async (
448
755
  .replace(/<script[\s\S]*?<\/script>/gi, '')
449
756
  .replace(/<style[\s\S]*?<\/style>/gi, '');
450
757
 
451
-
758
+ profiler.start('getBlock');
452
759
  let blockCode = await getBlock(options);
760
+ profiler.end('getBlock');
453
761
 
454
762
  // Ensure all <img> tags are self-closing for valid JSX
455
763
  blockCode = blockCode.replace(/<img([^>/]*?)>/g, '<img$1 />');
@@ -458,34 +766,31 @@ const block = async (
458
766
  const indexFile = getPhp(options);
459
767
  let blockFile = '';
460
768
 
461
- try {
462
- blockFile = transformBlockFile(blockCode).code
463
- ?.replace(/name: \"\{field.name\}\"/g, 'name: field.name')
464
- ?.replace(/key: \"\{index\}\"/g, 'key: index');
465
- } catch (error) {
466
- console.log(error);
467
- }
769
+ profiler.start('transformBlockFile');
770
+ blockFile = transformBlockFile(blockCode).code
771
+ ?.replace(/name: \"\{field.name\}\"/g, 'name: field.name')
772
+ ?.replace(/key: \"\{index\}\"/g, 'key: index');
773
+ profiler.end('transformBlockFile');
468
774
 
469
- if (shouldSaveFiles) {
470
- try {
471
- saveFile('style.css', scopedCssFrontend, options);
472
- saveFile('editor.css', editorStyleFile, options);
473
- saveFile('scripts.js', `${scriptFile}\n\n${emailTemplate}`, options);
474
- saveFile('index.php', indexFile, options);
475
- saveFile('block.js', blockFile, options);
476
- saveFile('remote-loader.js', '', options);
477
- } catch (error) {
478
- console.log(error);
479
-
480
- }
775
+ const finalScriptsFile = `${scriptFile}\n\n${emailTemplate}`;
776
+ const remoteLoaderFile = '';
777
+
778
+ if (shouldSaveFiles && outputMode === 'legacy') {
779
+ saveFile('style.css', scopedCssFrontend, options);
780
+ saveFile('editor.css', editorStyleFile, options);
781
+ saveFile('scripts.js', finalScriptsFile, options);
782
+ saveFile('index.php', indexFile, options);
783
+ saveFile('block.js', blockFile, options);
784
+ saveFile('remote-loader.js', remoteLoaderFile, options);
481
785
  }
482
786
 
483
787
  return {
484
788
  'style.css': scopedCssFrontend,
485
789
  'editor.css': editorStyleFile,
486
- 'scripts.js': scriptFile,
790
+ 'scripts.js': finalScriptsFile,
487
791
  'index.php': indexFile,
488
792
  'block.js': blockFile,
793
+ 'remote-loader.js': remoteLoaderFile,
489
794
  }
490
795
 
491
796
  };
@@ -571,11 +876,20 @@ const block = async (
571
876
  const loadHtml = async (options) => {
572
877
  const { basePath, htmlContent } = options;
573
878
  if (htmlContent) {
574
- const newHtml = await extractAssets(htmlContent, {
575
- basePath,
576
- saveFile: true,
577
- verbose: false,
578
- });
879
+ const extracted = await extractAssets(
880
+ htmlContent,
881
+ buildAssetExtractionOptions(basePath, {
882
+ uploadToR2: useR2Storage,
883
+ returnDetails: useR2Storage,
884
+ jobId,
885
+ r2Prefix: `generated/${jobId}/assets`,
886
+ })
887
+ );
888
+ const newHtml = typeof extracted === 'string' ? extracted : extracted.html;
889
+
890
+ if (typeof extracted !== 'string' && Array.isArray(extracted.assets)) {
891
+ extractedAssets.push(...extracted.assets);
892
+ }
579
893
 
580
894
  return cheerio.load(newHtml, {
581
895
  xmlMode: true,
@@ -639,7 +953,7 @@ const block = async (
639
953
  };
640
954
  const setImageAttribute = (properties) => {
641
955
  const { imgTag, imgSrc, imgAlt, attribute, type, prefix } = properties;
642
- const newPrefix = prefix ? replaceUnderscoresSpacesAndUppercaseLetters(prefix) : 'wp';
956
+ const newPrefix = replaceUnderscoresSpacesAndUppercaseLetters(prefix);
643
957
  const randomVariable = generateRandomVariableName(`${type}${newPrefix}`);
644
958
 
645
959
  let imgSrcWithoutOrigin = imgSrc;
@@ -885,10 +1199,12 @@ const block = async (
885
1199
 
886
1200
  content = `<div className="custom-block">${content}</div>`;
887
1201
 
888
- return await processEditImages({
1202
+ const processedImages = await processEditImages({
889
1203
  ...options,
890
1204
  htmlContent: parseContent(content),
891
1205
  });
1206
+
1207
+ return replaceSVGImages(processedImages);
892
1208
  };
893
1209
 
894
1210
  const createPanel = (values) => {
@@ -929,8 +1245,6 @@ const block = async (
929
1245
  });
930
1246
  // Replace with JSX template, preserving parent context
931
1247
  result += getSvgTemplate(svgBlock, group1, '</svg>', randomSVGVariable);
932
- } else {
933
- result += svgBlock;
934
1248
  }
935
1249
  lastIndex = end;
936
1250
  }
@@ -938,8 +1252,7 @@ const block = async (
938
1252
  return result;
939
1253
  };
940
1254
  const getSvgPanelTemplate = (panel) => {
941
- return panel.attributes && attributes[panel.attributes]
942
- ? `
1255
+ return `
943
1256
  { (
944
1257
  <PanelBody title="${panel.title}">
945
1258
  <PanelRow>
@@ -957,7 +1270,6 @@ const block = async (
957
1270
  </PanelBody>
958
1271
  )}
959
1272
  `
960
- : '';
961
1273
  };
962
1274
 
963
1275
  const getMediaPanelTemplate = (panel) => {
@@ -967,10 +1279,7 @@ const block = async (
967
1279
  ${panel.attributes[1]}: media.alt`
968
1280
  : '';
969
1281
 
970
- return panel.attributes &&
971
- panel.attributes[0] &&
972
- attributes[panel.attributes[0]]
973
- ? `
1282
+ return `
974
1283
  <PanelBody title="${panel.title}">
975
1284
  <PanelRow>
976
1285
  <div>
@@ -989,8 +1298,7 @@ const block = async (
989
1298
  </div>
990
1299
  </PanelRow>
991
1300
  </PanelBody>
992
- `
993
- : '';
1301
+ `;
994
1302
  };
995
1303
 
996
1304
  const getFormSettingsPanelTemplate = (panel) => {
@@ -1083,7 +1391,7 @@ const block = async (
1083
1391
  formIdAttr,
1084
1392
  ] = panel.attributes;
1085
1393
 
1086
- emailTemplate += sendEmailAttr ? getEmailSaveTemplate(formIdAttr) : '';
1394
+ emailTemplate += getEmailSaveTemplate(formIdAttr);
1087
1395
  phpEmailData += getPhpEmailData(formIdAttr, fromAttr, toAttr, subjectAttr, messageAttr);
1088
1396
 
1089
1397
  return `
@@ -1280,32 +1588,9 @@ const block = async (
1280
1588
  };
1281
1589
 
1282
1590
  const buildSaveContent = (editContent) => {
1283
- // Ensure RichText and RichText.Content are always self-closing
1284
- let output = editContent.replace(
1285
- /<RichText((.|\n)*?)value=\{(.*?)\}((.|\n)*?)>([\s\S]*?)<\/RichText>/gi,
1286
- '<RichText.Content value={$3} />'
1287
- );
1288
- output = output.replace(
1289
- /<RichText((.|\n)*?)value=\{(.*?)\}((.|\n)*?)\/>/gi,
1290
- '<RichText.Content value={$3} />'
1291
- );
1292
-
1293
- // If a wrapper (e.g., span) contains only RichText.Content, keep the wrapper and its attributes
1294
- output = output.replace(
1295
- /(<([a-zA-Z0-9]+)([^>]*)>\s*)<RichText\.Content value=\{([^}]+)\} \/>(\s*<\/\2>)/gi,
1296
- (match, openTag, tagName, attrs, value, closeTag) => {
1297
- // Keep the wrapper and its attributes, insert RichText.Content inside
1298
- return `${openTag}<RichText.Content value={${value}} />${closeTag}`;
1299
- }
1300
- );
1301
-
1591
+ let output = replaceRichTextComponents(editContent);
1302
1592
  output = output.replaceAll('class=', 'className=');
1303
- output = output.replace(
1304
- /<MediaUpload\b[^>]*>([\s\S]*?(<img\b[^>]*>*\/>)[\s\S]*?)\/>/g,
1305
- (_match, _attributes, img) => {
1306
- return img.replace(/onClick={[^}]+}\s*/, '');
1307
- }
1308
- );
1593
+ output = replaceMediaUploadComponents(output, images);
1309
1594
  return output;
1310
1595
  };
1311
1596
 
@@ -1329,45 +1614,16 @@ const block = async (
1329
1614
  )
1330
1615
  : undefined;
1331
1616
 
1332
- htmlContent = unwrapAnchor(htmlContent);
1333
-
1334
1617
  return {
1335
1618
  ...options,
1336
1619
  htmlContent,
1337
1620
  };
1338
1621
  };
1339
1622
 
1340
- const unwrapAnchor = (htmlContent) => {
1341
- return htmlContent.replace(
1342
- /<span([^>]*)>\s*<a([^>]*)>(.*?)<\/a>\s*<\/span>/gi,
1343
- (_, spanAttrs, anchorAttrs, content) => {
1344
- const allAttrs = {};
1345
- const attrRegex = /(\S+)=["'](.*?)["']/g;
1346
-
1347
- let match;
1348
- while ((match = attrRegex.exec(spanAttrs)) !== null) {
1349
- allAttrs[match[1]] = match[2];
1350
- }
1351
-
1352
- while ((match = attrRegex.exec(anchorAttrs)) !== null) {
1353
- allAttrs[match[1]] = match[2];
1354
- }
1355
-
1356
- return `<a ${Object.entries(allAttrs)
1357
- .map(([key, value]) => `${key}="${value}"`)
1358
- .join(' ')}>${content}</a>`;
1359
- }
1360
- );
1361
- };
1362
-
1363
1623
  const getComponentAttributes = () => {
1364
1624
  return Object.entries(attributes)
1365
1625
  .map(([key, value]) => {
1366
- if (typeof value === 'object' && value !== null) {
1367
- return `${key}: { ${Object.entries(value).map(([k, v]) => `${k}: \`${v}\``).join(', ')} }`;
1368
- } else {
1369
- return `${key}:\`${value}\``;
1370
- }
1626
+ return `${key}: { ${Object.entries(value).map(([k, v]) => `${k}: \`${v}\``).join(', ')} }`;
1371
1627
  })
1372
1628
  .join(',\n');
1373
1629
  };
@@ -1376,11 +1632,14 @@ const block = async (
1376
1632
  let { htmlContent } = options;
1377
1633
 
1378
1634
  if (htmlContent) {
1635
+ profiler.start('getEdit');
1379
1636
  options.htmlContent = unwrapBody(htmlContent);
1380
1637
  const postProcessLinks = processLinks(options);
1381
1638
  const postGetEditJsx = await getEditJsxContent(postProcessLinks);
1382
1639
  const preConvert = await postGetEditJsx.replace(/<\/br>/g, '<br/>').replace(/<\/hr>/g, '<hr/>')
1383
- return convert(preConvert)
1640
+ const converted = convert(preConvert);
1641
+ profiler.end('getEdit');
1642
+ return converted;
1384
1643
  }
1385
1644
 
1386
1645
  return '';
@@ -1399,13 +1658,54 @@ const block = async (
1399
1658
  } = settings;
1400
1659
 
1401
1660
  const iconPreview = generateIconPreview ? `(<img src={vars.url + 'preview.jpeg'} />)` : "'shield'";
1661
+ const categoryRegistration = registerCategoryIfMissing
1662
+ ? `
1663
+ (function ensureBlockCategory() {
1664
+ if (
1665
+ typeof wp === 'undefined' ||
1666
+ !wp.blocks ||
1667
+ !wp.blocks.setCategories ||
1668
+ !wp.blocks.store ||
1669
+ !wp.data ||
1670
+ !wp.data.select
1671
+ ) {
1672
+ return;
1673
+ }
1674
+
1675
+ const categorySelector = wp.data.select(wp.blocks.store);
1676
+ const existingCategories =
1677
+ categorySelector && typeof categorySelector.getCategories === 'function'
1678
+ ? categorySelector.getCategories()
1679
+ : [];
1680
+
1681
+ if (existingCategories.some((item) => item && item.slug === ${JSON.stringify(category)})) {
1682
+ return;
1683
+ }
1684
+
1685
+ wp.blocks.setCategories([
1686
+ ...existingCategories,
1687
+ {
1688
+ slug: ${JSON.stringify(category)},
1689
+ title: ${JSON.stringify(formatCategoryLabel(category) || category)},
1690
+ },
1691
+ ]);
1692
+ })();
1693
+ `
1694
+ : '';
1695
+ profiler.start('getBlock:getEdit');
1402
1696
  const edit = await getEdit(settings);
1697
+ profiler.end('getBlock:getEdit');
1698
+ profiler.start('getBlock:buildSaveContent');
1403
1699
  const save = buildSaveContent(edit);
1700
+ profiler.end('getBlock:buildSaveContent');
1701
+ profiler.start('getBlock:createPanels');
1404
1702
  const blockPanels = createPanels();
1703
+ profiler.end('getBlock:createPanels');
1405
1704
 
1406
1705
  const output = `
1407
1706
  (function () {
1408
1707
  ${imports}
1708
+ ${categoryRegistration}
1409
1709
 
1410
1710
  registerBlockType('${blockName}', {
1411
1711
  title: '${name}',
@@ -1441,7 +1741,7 @@ const block = async (
1441
1741
  const setupVariables = async (htmlContent, options) => {
1442
1742
 
1443
1743
  const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
1444
- const linkRegex = /<link\b[^>]*((\brel=["']stylesheet["'])|\bhref=["'][^"']+\.css["'])[^>]*>/gi;
1744
+ const linkRegex = /<link\b[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']+\.css(?:\?[^"']*)?)["'][^>]*>/gi;
1445
1745
 
1446
1746
  let match;
1447
1747
 
@@ -1454,7 +1754,13 @@ const block = async (
1454
1754
  while ((match = linkRegex.exec(htmlContent)) !== null) {
1455
1755
  const url = match[1];
1456
1756
  const fetchCssPromise = fetch(url)
1457
- .then((response) => response.text())
1757
+ .then((response) => {
1758
+ if (!response.ok) {
1759
+ throw new Error(`HTTP error! Status: ${response.status}`);
1760
+ }
1761
+
1762
+ return response.text();
1763
+ })
1458
1764
  .then((css) => styles.push({ type: 'external', content: css }))
1459
1765
  .catch(() => console.warn(`Failed to fetch: ${url}`));
1460
1766
  fetchCssPromises.push(fetchCssPromise);
@@ -1468,25 +1774,24 @@ const block = async (
1468
1774
  return `${style.content}`;
1469
1775
  }).join('\n');
1470
1776
 
1471
- const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/gi;
1777
+ const scriptRegex = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
1472
1778
  const scriptSrcRegex =
1473
1779
  /<script\s+[^>]*src=["']([^"']+)["'][^>]*>\s*<\/script>/gi;
1474
1780
 
1475
1781
  let jsMatch;
1476
1782
 
1477
- htmlContent = htmlContent.replace(scriptRegex, (_fullMatch, jsContent) => {
1478
- if (jsContent.trim()) {
1479
- scripts.push({ type: 'inline', content: jsContent });
1480
- }
1481
- return '';
1482
- });
1483
-
1484
1783
  const fetchJsPromises = [];
1485
1784
 
1486
1785
  while ((jsMatch = scriptSrcRegex.exec(htmlContent)) !== null) {
1487
1786
  const url = jsMatch[1];
1488
1787
  const fetchJsPromise = fetch(url)
1489
- .then((response) => response.text())
1788
+ .then((response) => {
1789
+ if (!response.ok) {
1790
+ throw new Error(`HTTP error! Status: ${response.status}`);
1791
+ }
1792
+
1793
+ return response.text();
1794
+ })
1490
1795
  .then((js) => scripts.push({ type: 'external', content: js }))
1491
1796
  .catch(() => console.warn(`Failed to fetch script: ${url}`));
1492
1797
  fetchJsPromises.push(fetchJsPromise);
@@ -1494,6 +1799,13 @@ const block = async (
1494
1799
 
1495
1800
  htmlContent = htmlContent.replace(scriptSrcRegex, '');
1496
1801
 
1802
+ htmlContent = htmlContent.replace(scriptRegex, (_fullMatch, jsContent) => {
1803
+ if (jsContent.trim()) {
1804
+ scripts.push({ type: 'inline', content: jsContent });
1805
+ }
1806
+ return '';
1807
+ });
1808
+
1497
1809
  await Promise.all(fetchJsPromises);
1498
1810
 
1499
1811
  js += scripts.map((script) => script.content).join('\n');
@@ -1503,9 +1815,10 @@ const block = async (
1503
1815
  cssFiles = [],
1504
1816
  jsFiles = [],
1505
1817
  name = 'My block',
1818
+ slug = replaceUnderscoresSpacesAndUppercaseLetters(name),
1506
1819
  } = options;
1507
1820
 
1508
- const newDir = path.join(basePath, replaceUnderscoresSpacesAndUppercaseLetters(name));
1821
+ const newDir = path.join(basePath, slug);
1509
1822
 
1510
1823
  const $ = cheerio.load(htmlContent, {
1511
1824
  xmlMode: true,
@@ -1516,22 +1829,17 @@ const block = async (
1516
1829
 
1517
1830
  htmlContent = $('body').html();
1518
1831
 
1519
- options.html
1520
-
1521
-
1522
- try {
1832
+ if (outputMode === 'legacy' && options.shouldSaveFiles) {
1523
1833
  fs.mkdirSync(newDir, { recursive: true });
1524
-
1525
- return {
1526
- ...options,
1527
- jsFiles,
1528
- cssFiles,
1529
- htmlContent,
1530
- basePath: newDir,
1531
- };
1532
- } catch (error) {
1533
- logError(error);
1534
1834
  }
1835
+
1836
+ return {
1837
+ ...options,
1838
+ jsFiles,
1839
+ cssFiles,
1840
+ htmlContent,
1841
+ basePath: newDir,
1842
+ };
1535
1843
  };
1536
1844
 
1537
1845
  if (source) {
@@ -1542,14 +1850,14 @@ const block = async (
1542
1850
 
1543
1851
 
1544
1852
  // Screenshot generation using SnapAPI
1545
- dotenv.config();
1853
+ dotenv.config({ quiet: true });
1546
1854
  if (options.generateIconPreview && options.source) {
1547
1855
  try {
1548
1856
  const snapApiKey = process.env.SNAPAPI_KEY;
1549
1857
  if (!snapApiKey) {
1550
1858
  throw new Error('SNAPAPI_KEY is not set in environment variables.');
1551
1859
  }
1552
- const snapApiUrl = 'https://api.snapapi.pics/v1/screenshot';
1860
+ const snapApiUrl = getSnapApiUrl();
1553
1861
  const snapApiBody = {
1554
1862
  url: options.source,
1555
1863
  fullPage: true,
@@ -1568,15 +1876,53 @@ const block = async (
1568
1876
  if (!response.ok) {
1569
1877
  throw new Error(`SnapAPI error: ${response.status}`);
1570
1878
  }
1571
- const previewPath = path.join(options.basePath, replaceUnderscoresSpacesAndUppercaseLetters(options.name), 'preview.jpeg');
1572
1879
  const buffer = Buffer.from(await response.arrayBuffer());
1573
- fs.writeFileSync(previewPath, buffer);
1880
+
1881
+ if (outputMode === 'legacy' && options.shouldSaveFiles) {
1882
+ const previewPath = path.join(options.basePath, options.slug, 'preview.jpeg');
1883
+ fs.writeFileSync(previewPath, buffer);
1884
+ } else if (useR2Storage) {
1885
+ const uploadResult = await uploadBufferToR2({
1886
+ storageKey: `generated/${jobId}/preview.jpeg`,
1887
+ body: buffer,
1888
+ contentType: 'image/jpeg',
1889
+ });
1890
+
1891
+ previewArtifact = {
1892
+ buffer,
1893
+ file: createFileRecord({
1894
+ id: `file_preview`,
1895
+ name: 'preview.jpeg',
1896
+ kind: 'asset',
1897
+ storageKey: uploadResult.storageKey,
1898
+ size: uploadResult.size,
1899
+ type: uploadResult.type,
1900
+ url: uploadResult.url,
1901
+ }),
1902
+ };
1903
+ }
1574
1904
  } catch (error) {
1575
1905
  console.log(`There was an error generating preview with SnapAPI. ${error.message}`);
1576
1906
  }
1577
1907
  }
1578
1908
 
1579
- return saveFiles(await setupVariables(htmlContent, options));
1909
+ profiler.start('setupVariables');
1910
+ const preparedOptions = await setupVariables(htmlContent, options);
1911
+ profiler.end('setupVariables');
1912
+ profiler.start('saveFiles');
1913
+ const result = await saveFiles(preparedOptions);
1914
+ profiler.end('saveFiles');
1915
+
1916
+ if (outputMode === 'legacy') {
1917
+ return result;
1918
+ }
1919
+
1920
+ return uploadJobPackage({
1921
+ jobId,
1922
+ generatedFiles: result,
1923
+ assetFiles: extractedAssets,
1924
+ previewArtifact,
1925
+ });
1580
1926
  };
1581
1927
 
1582
- export default block;
1928
+ export default block;