UNPKG

html-to-gutenberg

Version:

Transform any valid HTML string into fully editable WP Gutenberg blocks in seconds rather than hours.

1,637 lines (1,386 loc) 58.3 kB
import fetch from 'node-fetch'; import presetReact from '@babel/preset-react'; import * as babel from '@babel/core'; import * as cheerio from 'cheerio'; import scopeCss from 'css-scoping'; import extractAssets from 'fetch-page-assets'; import fs from 'fs'; import dotenv from 'dotenv'; import { createRequire } from 'module'; import convert from 'node-html-to-jsx'; import path from 'path'; import { createFileRecord, createJobId, inferContentType, uploadBufferToR2, zipEntriesToBuffer, } from './r2.js'; import { imports, images, characters } from './globals.js'; const require = createRequire(import.meta.url); const { version } = require('./package.json'); export const createProfiler = (enabled) => { const marks = new Map(); return { start(label) { if (enabled) { marks.set(label, process.hrtime.bigint()); } }, end(label) { if (enabled && marks.has(label)) { const start = marks.get(label); const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6; console.log(`[profile] ${label}: ${elapsedMs.toFixed(2)}ms`); marks.delete(label); } }, }; }; export const findSelfClosingJsxEnd = (content, startIndex) => { let inSingleQuote = false; let inDoubleQuote = false; let inTemplateQuote = false; let braceDepth = 0; let parenDepth = 0; for (let i = startIndex; i < content.length - 1; i++) { const char = content[i]; const next = content[i + 1]; const prev = i > 0 ? content[i - 1] : ''; const escaped = prev === '\\'; if (!escaped) { if (!inDoubleQuote && !inTemplateQuote && char === '\'') { inSingleQuote = !inSingleQuote; continue; } if (!inSingleQuote && !inTemplateQuote && char === '"') { inDoubleQuote = !inDoubleQuote; continue; } if (!inSingleQuote && !inDoubleQuote && char === '`') { inTemplateQuote = !inTemplateQuote; continue; } } if (inSingleQuote || inDoubleQuote || inTemplateQuote) { continue; } if (char === '{') { braceDepth++; continue; } if (char === '}') { braceDepth = Math.max(0, braceDepth - 1); continue; } if (char === '(') { parenDepth++; continue; } if (char === ')') { parenDepth = Math.max(0, parenDepth - 1); continue; } if (char === '/' && next === '>' && braceDepth === 0 && parenDepth === 0) { return i + 2; } } return -1; }; export const replaceSelfClosingJsxComponent = (content, componentName, replacer) => { const openTag = `<${componentName}`; if (!content.includes(openTag)) { return content; } let cursor = 0; let result = ''; while (cursor < content.length) { const start = content.indexOf(openTag, cursor); if (start === -1) { result += content.slice(cursor); break; } result += content.slice(cursor, start); const end = findSelfClosingJsxEnd(content, start); if (end === -1) { result += content.slice(start); break; } result += replacer(content.slice(start, end)); cursor = end; } return result; }; export const getMediaUploadSaveTemplate = (image) => { if (!image) { return ''; } const { randomUrlVariable, randomAltVariable, imgClass } = image; const classNameAttr = imgClass ? ` className="${imgClass}"` : ''; return `<img src={attributes.${randomUrlVariable}} alt={attributes.${randomAltVariable}}${classNameAttr} />`; }; export const replaceMediaUploadComponents = (content, imageRegistry) => { let imageIndex = 0; return replaceSelfClosingJsxComponent(content, 'MediaUpload', () => { const template = getMediaUploadSaveTemplate(imageRegistry[imageIndex]); imageIndex++; return template; }); }; export const replaceRichTextComponents = (content) => { return replaceSelfClosingJsxComponent(content, 'RichText', (componentSource) => { const valueMatch = componentSource.match(/\bvalue=\{([^}]+)\}/); if (!valueMatch) { return componentSource; } return `<RichText.Content value={${valueMatch[1]}} />`; }); }; export const buildAssetExtractionOptions = (basePath, options = {}) => ({ basePath, saveFile: false, verbose: false, maxRetryAttempts: 1, retryDelay: 0, concurrency: 8, uploadToR2: options.uploadToR2 || false, returnDetails: options.returnDetails || false, jobId: options.jobId, r2Prefix: options.r2Prefix, }); export const slugifyBlockValue = (value = '') => { return String(value) .replace(/\W|_/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); }; export const formatCategoryLabel = (category = '') => { return String(category) .split(/[-_\s]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); }; export const normalizeBlockOptions = (options = {}) => { const hasOwn = (key) => Object.prototype.hasOwnProperty.call(options, key); const title = options.title ?? options.name ?? 'My block'; const slug = slugifyBlockValue(options.slug ?? title) || 'my-block'; const namespace = options.namespace ?? options.prefix ?? 'wp'; const baseUrl = options.baseUrl ?? options.source ?? null; const outputPath = options.outputPath ?? options.basePath ?? process.cwd(); const hasExplicitLocalOutputPreference = hasOwn('writeFiles') || hasOwn('outputPath') || hasOwn('shouldSaveFiles') || hasOwn('basePath'); const hasExplicitJobPreference = hasOwn('uploadToR2') || hasOwn('jobId'); const outputMode = options.outputMode ?? (hasExplicitJobPreference ? 'job' : hasExplicitLocalOutputPreference ? 'legacy' : 'job'); const writeFiles = options.writeFiles ?? options.shouldSaveFiles ?? (outputMode === 'legacy'); const generatePreviewImage = options.generatePreviewImage ?? options.generateIconPreview ?? false; return { ...options, title, name: title, slug, namespace, prefix: namespace, baseUrl, source: baseUrl, category: options.category ?? 'common', registerCategoryIfMissing: options.registerCategoryIfMissing ?? false, outputPath, basePath: outputPath, writeFiles, shouldSaveFiles: writeFiles, generatePreviewImage, generateIconPreview: generatePreviewImage, jsFiles: options.jsFiles ?? [], cssFiles: options.cssFiles ?? [], outputMode, uploadToR2: options.uploadToR2 ?? outputMode === 'job', jobId: options.jobId, }; }; export const replaceRelativeUrls = (html, replacer) => { const urlAttributes = [ 'src', 'href', 'action', 'srcset', 'poster', 'data', 'formaction' ]; const regex = new RegExp( `\\b(${urlAttributes.join('|')}|data-[a-zA-Z0-9_-]+)\\s*=\\s*(['"])(?!https?:|//|mailto:|tel:|#)([^'"]+)\\2`, 'gi' ); return html.replace(regex, (_match, attr, quote, url) => { const newUrl = replacer(url); return `${attr}=${quote}${newUrl}${quote}`; }); }; export const replaceRelativeUrlsInCss = (css, replacer) => { const regex = /url\(\s*(['"]?)(.+?)\1\s*\)/gi; return css.replace(regex, (match, quote, url) => { if (/^(https?:|\/\/|data:|mailto:|tel:|#)/.test(url.trim())) { return match; } const newUrl = replacer(url); return `url(${quote}${newUrl}${quote})`; }); }; export const replaceRelativeUrlsInHtml = (html, baseUrl) => { return replaceRelativeUrls(html, (url) => { return new URL(url, baseUrl).href; }); }; export const replaceRelativeUrlsInCssWithBase = (css, cssFileUrl) => { return replaceRelativeUrlsInCss(css, (url) => { return new URL(url, cssFileUrl).href; }); }; export const unwrapBody = (code) => { try { return code.replace(/<\/?(html|body)[^>]*>/gi, ''); } catch (_error) { return code; } }; export const transformBlockFile = (blockCode) => { let transformedCode = ''; try { transformedCode = babel.transformSync(blockCode, { presets: [[presetReact, { pragma: 'wp.element.createElement' }]], filename: 'block.js' }); } catch (error) { console.log(error); } return transformedCode; }; export const getSnapApiUrl = () => { return process.env.SNAPAPI_URL || 'https://api.snapapi.pics/v1/screenshot'; }; const uploadJobPackage = async ({ jobId, generatedFiles, assetFiles, previewArtifact }) => { const allFiles = []; const zipEntries = []; let sourceIndex = 1; let assetIndex = 1; for (const [name, contents] of Object.entries(generatedFiles)) { const body = typeof contents === 'string' ? Buffer.from(contents, 'utf8') : Buffer.from(contents || ''); const storageKey = `generated/${jobId}/${name}`; const uploadResult = await uploadBufferToR2({ storageKey, body, contentType: inferContentType(name), }); allFiles.push( createFileRecord({ id: `file_${sourceIndex++}`, name, kind: 'source', storageKey: uploadResult.storageKey, size: uploadResult.size, type: uploadResult.type, url: uploadResult.url, }) ); zipEntries.push({ name, body }); } for (const assetFile of assetFiles) { allFiles.push({ id: `file_${sourceIndex + assetIndex - 1}`, name: assetFile.name, type: assetFile.type, size: assetFile.size, path: assetFile.path, url: assetFile.url, kind: assetFile.kind || 'asset', }); if (assetFile.buffer) { zipEntries.push({ name: path.posix.relative(`generated/${jobId}`, assetFile.path.replace(/^\//, '')), body: assetFile.buffer, }); } assetIndex++; } if (previewArtifact) { allFiles.push(previewArtifact.file); zipEntries.push({ name: previewArtifact.file.name, body: previewArtifact.buffer, }); } const zipBuffer = await zipEntriesToBuffer(zipEntries); const bundleUpload = await uploadBufferToR2({ storageKey: `generated/${jobId}/output.zip`, body: zipBuffer, contentType: 'application/zip', }); const bundleFile = createFileRecord({ id: 'file_bundle', name: 'output.zip', kind: 'bundle', storageKey: bundleUpload.storageKey, size: bundleUpload.size, type: bundleUpload.type, url: bundleUpload.url, }); return { jobId, status: 'completed', output: { files: allFiles, bundle: { id: bundleFile.id, name: bundleFile.name, type: bundleFile.type, size: bundleFile.size, path: bundleFile.path, url: bundleFile.url, zipUrl: bundleUpload.url, }, }, }; }; const block = async ( htmlContent, rawOptions = {} ) => { const options = normalizeBlockOptions(rawOptions); const panels = []; const styles = []; const scripts = []; const attributes = {}; const formVars = {}; const extractedAssets = []; images.length = 0; const { name, prefix, source, slug, registerCategoryIfMissing } = options; const outputMode = options.outputMode; const useR2Storage = options.uploadToR2; const jobId = options.jobId || createJobId(); let js = ''; let css = ''; let phpEmailData = ''; let emailTemplate = ''; let previewArtifact = null; const profiler = createProfiler(process.env.HTG_PROFILE === '1'); function hasTailwindCdnSource(jsFiles) { const tailwindCdnRegex = /https:\/\/(cdn\.tailwindcss\.com(\?[^"'\s]*)?|cdn\.jsdelivr\.net\/npm\/@tailwindcss\/browser@4(\.\d+){0,2})/; return jsFiles.some(url => tailwindCdnRegex.test(url)); } function replaceSourceUrlVars(str, source) { if (!source) return str; const escapedSource = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`var\.url\+'${escapedSource}([^']+)'`, 'g'); return str.replace(pattern, (match, path) => `\${vars.url}${path}`); } function sanitizeAndReplaceLeadingNumbers(str) { const numberWords = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; let firstNumberReplaced = false; return str .toLowerCase() .replace(/[\s\-_]/g, '') .replace(/\d/g, (digit) => { if (!firstNumberReplaced) { firstNumberReplaced = true; return numberWords[parseInt(digit)] + digit; } return digit; }) .replace(/^[^a-z]+/, ''); } const replaceUnderscoresSpacesAndUppercaseLetters = (name = '') => slugifyBlockValue(name); const saveFile = (fileName, contents, options) => { try { const filePath = path.join(options.basePath, fileName); fs.writeFileSync(filePath, contents); return contents; } catch (error) { logError(error); } }; const parseRequirements = async (files, options) => { const { source } = options; let output = ''; for (const file of files) { try { const response = await fetch(file); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } let data = await response.text(); if (source) { data = replaceRelativeUrlsInCssWithBase(data, file); } output += `${data}\n\n`; } catch (error) { logError(error); } } return output; }; const convertDashesSpacesAndUppercaseToUnderscoresAndLowercase = (string) => { if (string) { return `${string.replaceAll('-', '_').replaceAll(' ', '_').toLowerCase()}`; } return ''; }; const newName = slug; const normalizedNamespace = replaceUnderscoresSpacesAndUppercaseLetters(prefix); const blockName = `${sanitizeAndReplaceLeadingNumbers(normalizedNamespace)}/${sanitizeAndReplaceLeadingNumbers(newName)}`; const blockNameHandle = `${normalizedNamespace}-${newName}`; const getPhp = (options) => { const { name, prefix, jsFiles, cssFiles } = options; const phpName = convertDashesSpacesAndUppercaseToUnderscoresAndLowercase(name); const phpPrefix = `${functionSufix}${convertDashesSpacesAndUppercaseToUnderscoresAndLowercase(prefix)}`; const tailwindFix = hasTailwindCdnSource(jsFiles) ? 'function forceTailwindUpdate(){let e=setInterval(()=>{if("undefined"!=typeof tailwind){clearInterval(e);let n=document.documentElement.outerHTML;tailwind.config.content=[{raw:n,extension:"html"}]}},100)}forceTailwindUpdate();': ''; const tailwindFooter = hasTailwindCdnSource(jsFiles) ? '(()=>{if(window.__tailwindObserverActive)return;window.__tailwindObserverActive=!0;let e=new MutationObserver(()=>{let t=[...document.querySelectorAll("style")].find(e=>e.innerText.includes("--tw-"));t&&(document.body.appendChild(t),e.disconnect(),window.__tailwindObserverActive=!1)});e.observe(document.documentElement,{childList:!0,subtree:!0})})();' : ''; const inlineRemoteLoader = `var remoteUrls = ${JSON.stringify(jsFiles)};(function loadScripts() {window._loadedRemoteScripts = window._loadedRemoteScripts || new Set();const style = document.createElement('style');remoteUrls.forEach((url) => {if (window._loadedRemoteScripts.has(url)) return;const script = document.createElement('script');script.src = url;document.head.appendChild(script);window._loadedRemoteScripts.add(url);});})();${tailwindFix}${tailwindFooter}`; const enqueueRemoteStyles = cssFiles .map((remoteUrl) => { return ` wp_enqueue_style( '${blockNameHandle}-${remoteUrl .split('/') .pop() .replace(/\.\w+$/, '')}', '${remoteUrl}', array(), null ); `; }) .join('\n'); return `<?php /* * Plugin Name: ${name} * Version: ${version} * Author: Html to Gutenberg * Author URI: https://www.html-to-gutenberg.io/ * Description: A custom editable block built with Html to Gutenberg */ if ( ! defined( 'ABSPATH' ) ) { exit; } function parse_form_placeholders_${functionSufix}($content, $post_data) { return preg_replace_callback('/{{(.*?)}}/', function ($matches) use ($post_data) { $key = trim($matches[1]); return isset($post_data[$key]) ? sanitize_text_field($post_data[$key]) : ''; }, $content); } add_action('wp_ajax_send_test_email_${functionSufix}}', function() { //check_ajax_referer('wp_rest'); $post_data = $_POST; $to = sanitize_email(parse_form_placeholders_${functionSufix}($post_data['to'], $post_data)); $from = sanitize_email(parse_form_placeholders_${functionSufix}($post_data['from'], $post_data)); $subject = sanitize_text_field(parse_form_placeholders_${functionSufix}($post_data['subject'], $post_data)); $message = wp_kses_post(parse_form_placeholders_${functionSufix}($post_data['message'], $post_data)); if (empty($to) || empty($from)) { wp_send_json_error('Missing email addresses.'); } $sent = wp_mail($to, $subject, $message, [ 'Content-Type: text/html; charset=UTF-8', 'From: ' . $from ]); if ($sent) { wp_send_json_success('Email sent'); } else { error_log('Failed to send. To: ' . $to . ' | From: ' . $from); wp_send_json_error('Failed to send email.'); } }); ${phpEmailData} function ${phpPrefix}_${phpName}_add_custom_editor_styles() { echo '<style>span.block-editor-rich-text__editable.rich-text{all:unset!important}a br[data-rich-text-line-break=true],span.block-editor-block-icon.block-editor-block-switcher__toggle.has-colors img{display:none}.block-editor-block-types-list__list-item{width:100%!important}.block-editor-block-list__layout.is-root-container>:where(:not(.alignleft):not(.alignright):not(.alignfull)){max-width:100%;margin:0}[aria-label="Empty block; start writing or type forward slash to choose a block"]{max-width:1200px!important}span.block-editor-block-types-list__item-icon img{max-width:100%;width:100%;margin:0;display:block}span.block-editor-block-icon.has-colors{all:inherit;order:2;flex:0 0 100%;width:100%}span.block-editor-block-icon.has-colors svg{margin-left:auto;margin-right:auto}.block-editor-block-card{display:flex!important;flex-wrap:wrap}.block-editor-inserter__preview-content-missing{display:none!important}</style>'; } add_action('admin_footer', function () { $screen = get_current_screen(); if ($screen && method_exists($screen, 'is_block_editor') && $screen->is_block_editor()) { $href = esc_url(plugins_url('editor.css', __FILE__)); echo "<link rel='stylesheet' id='${blockNameHandle}-style' href='$href' type='text/css' media='all' />"; } }); add_action( 'enqueue_block_editor_assets', '${phpPrefix}_${phpName}_editor_assets' ); add_action('wp_enqueue_scripts', function() { wp_dequeue_style('wp-fonts-local'); wp_deregister_style('wp-fonts-local'); wp_dequeue_style('global-styles'); wp_deregister_style('global-styles'); remove_action('wp_footer', 'wp_global_styles_render_svg_filters'); }, 100); function ${phpPrefix}_${phpName}_editor_assets() { $filepath = plugin_dir_path(__FILE__) . 'block.js'; $version = file_exists($filepath) ? filemtime($filepath) : time(); wp_enqueue_script( '${blockNameHandle}', plugins_url( 'block.js', __FILE__ ), array( 'wp-blocks', 'wp-components', 'wp-element' ,'wp-editor'), $version ); wp_localize_script( '${blockNameHandle}', 'vars', array( 'url' => plugin_dir_url( __FILE__ ) ) ); ${enqueueRemoteStyles} ${phpPrefix}_${phpName}_add_custom_editor_styles(); wp_dequeue_style('${blockNameHandle}-frontend'); wp_deregister_style('${blockNameHandle}-frontend'); wp_enqueue_script( '${blockNameHandle}-remote-loader', plugins_url('remote-loader.js', __FILE__), array(), null, true ); wp_add_inline_script( '${blockNameHandle}-remote-loader', ${JSON.stringify(inlineRemoteLoader)} ); } add_action('enqueue_block_editor_assets', function () { wp_dequeue_style('wp-block-library'); wp_dequeue_style('wp-block-library-theme'); wp_dequeue_style('wc-block-style'); wp_dequeue_style('wp-format-library'); }, 100); add_action('init', function () { wp_register_script( '${blockNameHandle}-scripts', plugins_url('scripts.js', __FILE__), array(), null, true ); wp_register_style( '${blockNameHandle}-frontend', plugins_url('style.css', __FILE__) ); wp_register_script( '${blockNameHandle}-remote-loader', plugins_url('remote-loader.js', __FILE__), array(), null, true ); }); add_action( 'wp_enqueue_scripts', '${phpPrefix}_${phpName}_block_assets', 999 ); function ${phpPrefix}_${phpName}_block_assets() { global $wp_query; $used = false; if (!empty($wp_query->posts)) { foreach ($wp_query->posts as $post) { $blocks = parse_blocks($post->post_content); foreach ($blocks as $block) { if ($block['blockName'] === '${blockName}') { $used = true; break 2; } } } } if ($used) { $handle = '${blockNameHandle}'; wp_enqueue_style($handle . '-frontend'); wp_enqueue_script($handle . '-scripts'); wp_localize_script( $handle . '-scripts', 'vars', array( 'postId' => get_queried_object_id(), 'ajaxUrl' => admin_url('admin-ajax.php') ) ); wp_enqueue_script($handle . '-remote-loader'); wp_add_inline_script( $handle . '-remote-loader', ${JSON.stringify(inlineRemoteLoader)} ); } } `; }; const saveFiles = async (options) => { const { cssFiles = [], jsFiles = [], shouldSaveFiles, name, prefix } = options; 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; const hasTailwind = tailwindRegex.test(htmlContent); const hasTailwindCdn = hasTailwindCdnSource(jsFiles); css = hasTailwind && hasTailwindCdn ? '' : ` *:not(.components-button) { all: revert-layer; }\n`; css += await parseRequirements(cssFiles, options); for (const style of styles) { css += style.content; } css = css.replace(/[^{}]+:is\([^)]*\[.*?['"].*?\)[^{}]*\{[^}]*\}/g, ''); const scopedCssFrontend = scopeCss(css, `.wp-block-${sanitizeAndReplaceLeadingNumbers(replaceUnderscoresSpacesAndUppercaseLetters(prefix))}-${sanitizeAndReplaceLeadingNumbers(replaceUnderscoresSpacesAndUppercaseLetters(name))}`); const editorStyleFile = scopeCss(css, `[data-type="${blockName}"]`); const scriptFile = js; htmlContent = htmlContent .replace(/<head[\s\S]*?<\/head>/gi, '') .replace(/<script[\s\S]*?<\/script>/gi, '') .replace(/<style[\s\S]*?<\/style>/gi, ''); profiler.start('getBlock'); let blockCode = await getBlock(options); profiler.end('getBlock'); // Ensure all <img> tags are self-closing for valid JSX blockCode = blockCode.replace(/<img([^>/]*?)>/g, '<img$1 />'); blockCode = blockCode.replaceAll(' / dangerouslySetInnerHTML', ' dangerouslySetInnerHTML') const indexFile = getPhp(options); let blockFile = ''; profiler.start('transformBlockFile'); blockFile = transformBlockFile(blockCode).code ?.replace(/name: \"\{field.name\}\"/g, 'name: field.name') ?.replace(/key: \"\{index\}\"/g, 'key: index'); profiler.end('transformBlockFile'); const finalScriptsFile = `${scriptFile}\n\n${emailTemplate}`; const remoteLoaderFile = ''; if (shouldSaveFiles && outputMode === 'legacy') { saveFile('style.css', scopedCssFrontend, options); saveFile('editor.css', editorStyleFile, options); saveFile('scripts.js', finalScriptsFile, options); saveFile('index.php', indexFile, options); saveFile('block.js', blockFile, options); saveFile('remote-loader.js', remoteLoaderFile, options); } return { 'style.css': scopedCssFrontend, 'editor.css': editorStyleFile, 'scripts.js': finalScriptsFile, 'index.php': indexFile, 'block.js': blockFile, 'remote-loader.js': remoteLoaderFile, } }; const logError = (error) => { console.error(`[Error] ${error.message}`); }; const generateRandomVariableName = (prefix = 'content', length = 3) => { let suffix = ''; for (let i = 0; i < length; i++) { suffix += characters.charAt( Math.floor(Math.random() * characters.length) ); } return `${prefix}${suffix}`; }; const functionSufix = generateRandomVariableName('func'); const setRandomAttributeContent = (randomVariableName, content) => { const isArray = Array.isArray(content); attributes[randomVariableName] = { type: isArray ? 'array' : 'string', default: content }; }; const hasAbsoluteKeyword = (str) => { return !!(str && str.toLowerCase().includes('absolute')); }; const getImageTemplate = (image) => { const { randomUrlVariable, randomAltVariable, imgClass } = image; return ` <MediaUpload onSelect={(media) => { setAttributes({ ${randomUrlVariable}: media.url, ${randomAltVariable}: media.alt }); }} type="image" render={({ open }) => ( <div style={{ position: 'relative' }}> <img src={attributes.${randomUrlVariable}} alt={attributes.${randomAltVariable}} className="${imgClass}" /> <div onClick={open} style={{ position: 'absolute', bottom: '0', width: '100%', height: '100%', zIndex: 10, cursor: 'pointer' }} ></div> </div> )} /> `; }; const replaceHtmlImage = (html, image) => { const { randomUrlVariable } = image; const regex = new RegExp(`<img\\s+[^>]*src=\\{[^}]*${randomUrlVariable}[^}]*\\}[^>]*>`, 'gi'); return html.replace(regex, getImageTemplate(image)); }; const replaceImageComponents = (html) => { images.forEach((image) => { html = replaceHtmlImage(html, image); }); return html; }; const loadHtml = async (options) => { const { basePath, htmlContent } = options; if (htmlContent) { const extracted = await extractAssets( htmlContent, buildAssetExtractionOptions(basePath, { uploadToR2: useR2Storage, returnDetails: useR2Storage, jobId, r2Prefix: `generated/${jobId}/assets`, }) ); const newHtml = typeof extracted === 'string' ? extracted : extracted.html; if (typeof extracted !== 'string' && Array.isArray(extracted.assets)) { extractedAssets.push(...extracted.assets); } return cheerio.load(newHtml, { xmlMode: true, decodeEntities: false, }); } }; const getImageSource = (imgTag) => { return imgTag.attr('src') || ''; }; const getImageAlt = (imgTag) => { return imgTag.attr('alt') || ''; }; const getParentElement = (imgTag) => { return imgTag.parent(); }; const getImageStyle = (imgTag) => { return imgTag.attr('style') || ''; }; const getImageClass = (imgTag) => { return imgTag.attr('class') || imgTag.attr('className') || ''; }; const getPreviousStyle = (parentElement) => { return parentElement.attr('style') || ''; }; const getParentClass = (parentElement) => { return parentElement.attr('class') || parentElement.attr('className') || ''; }; const isBackgroundImage = ( imgStyle, imgClass, previousStyle, previousClass ) => { return ( hasAbsoluteKeyword(imgStyle) || hasAbsoluteKeyword(imgClass) || hasAbsoluteKeyword(previousStyle) || hasAbsoluteKeyword(previousClass) ); }; const getImageProperties = (imgTag) => { const parentElement = getParentElement(imgTag); const imgStyle = getImageStyle(imgTag); const imgClass = getImageClass(imgTag); const previousStyle = getPreviousStyle(parentElement); const previousClass = getParentClass(parentElement); const isBackground = isBackgroundImage( imgStyle, imgClass, previousStyle, previousClass ); return { imgTag, imgClass, isBackground, imgSrc: getImageSource(imgTag), imgAlt: getImageAlt(imgTag), }; }; const setImageAttribute = (properties) => { const { imgTag, imgSrc, imgAlt, attribute, type, prefix } = properties; const newPrefix = replaceUnderscoresSpacesAndUppercaseLetters(prefix); const randomVariable = generateRandomVariableName(`${type}${newPrefix}`); let imgSrcWithoutOrigin = imgSrc; try { if (typeof imgSrc === 'string') { if (/^https?:\/\//.test(imgSrc)) { const urlObj = new URL(imgSrc); imgSrcWithoutOrigin = urlObj.pathname + urlObj.search + urlObj.hash; } else if (source && imgSrc.startsWith(source)) { imgSrcWithoutOrigin = imgSrc.slice(source.length); if (imgSrcWithoutOrigin.startsWith('/')) imgSrcWithoutOrigin = imgSrcWithoutOrigin.slice(1); } } } catch (e) { imgSrcWithoutOrigin = imgSrc; } let imgSrcNoLeadingSlash = imgSrcWithoutOrigin; if (typeof imgSrcNoLeadingSlash === 'string' && imgSrcNoLeadingSlash.startsWith('/')) { imgSrcNoLeadingSlash = imgSrcNoLeadingSlash.slice(1); } attributes[randomVariable] = { attribute, type: 'string', selector: 'img', default: attribute === 'alt' ? imgAlt : `{vars.url}${imgSrcNoLeadingSlash}`.replace(/^\u007f/, '$'), }; imgTag.attr(attribute, `{attributes.${randomVariable}}`); return randomVariable; }; const processImage = (properties) => { const { imgClass, type } = properties; const randomUrlVariable = setImageAttribute({ ...properties, attribute: 'src', prefix: 'Url', }); const randomAltVariable = setImageAttribute({ ...properties, attribute: 'alt', prefix: 'Alt', }); if (type !== 'background') { images.push({ randomUrlVariable, randomAltVariable, imgClass }); return; } createPanel({ type: 'media', title: 'Background Image', attributes: [randomUrlVariable, randomAltVariable], }); }; const createPanelsForForm = () => { const randomFormIdVariable = generateRandomVariableName('form'); const randomHiddenFieldsAttr = generateRandomVariableName('hiddenFields'); const randomSendEmailVariable = generateRandomVariableName('send'); const randomEmailFromVariable = generateRandomVariableName('emailFrom'); const randomEmailToVariable = generateRandomVariableName('emailTo'); const randomEmailSubjectVariable = generateRandomVariableName('emailSubj'); const randomEmailMessageVariable = generateRandomVariableName('emailMsg'); const randomTestFeedbackAttr = generateRandomVariableName('emailTestMsg'); Object.assign(formVars, { randomFormIdVariable, randomSendEmailVariable, randomEmailFromVariable, randomEmailToVariable, randomEmailSubjectVariable, randomEmailMessageVariable, randomTestFeedbackAttr, randomHiddenFieldsAttr }); setRandomAttributeContent(randomFormIdVariable, `form-${randomFormIdVariable}`); setRandomAttributeContent(randomSendEmailVariable, false); setRandomAttributeContent(randomEmailFromVariable, ''); setRandomAttributeContent(randomEmailToVariable, ''); setRandomAttributeContent(randomEmailSubjectVariable, 'New Form Submission'); setRandomAttributeContent(randomEmailMessageVariable, 'Form data:\n{{fields}}'); setRandomAttributeContent(randomTestFeedbackAttr, ''); setRandomAttributeContent(randomHiddenFieldsAttr, []); createPanel({ type: 'formSettings', title: 'Form Settings', attributes: [randomFormIdVariable, randomSendEmailVariable], }); createPanel({ type: 'emailSettings', title: 'Email Settings', attributes: [ randomSendEmailVariable, randomEmailFromVariable, randomEmailToVariable, randomEmailSubjectVariable, randomEmailMessageVariable, randomTestFeedbackAttr, randomFormIdVariable ], }); createPanel({ type: 'hiddenFields', title: 'Hidden Fields', attributes: [randomHiddenFieldsAttr], }); }; const getFormVariables = () => ({ ...formVars }); const transformFormToDynamicJSX = (htmlContent) => { const regex = /<form([\s\S]*?)>([\s\S]*?)<\/form>/gi const formExists = regex.test(htmlContent); if (!formExists) { return htmlContent; } return htmlContent.replace( /<form([\s\S]*?)>([\s\S]*?)<\/form>/gi, (_match, formAttributes, innerContent) => { createPanelsForForm(); const { randomFormIdVariable, randomHiddenFieldsAttr } = getFormVariables(); return ` <form ${formAttributes.trim()} id={attributes.${randomFormIdVariable}} > ${innerContent} { (attributes.${randomHiddenFieldsAttr} || []).map((field, index) => (<input key={index} name={field.name} value={field.value} type="hidden" /> )) } </form> `; } ); }; const getFixedHtml = (html) => { function parseStyleString(style) { const entries = style.split(';').filter(Boolean).map(rule => { let [key, value] = rule.split(':'); if (!key || !value) return null; key = key.trim().replace(/^[-\s]+/, ''); const camelKey = key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); return [camelKey, value.trim()]; }).filter(Boolean); const styleObject = Object.fromEntries(entries); return JSON.stringify(styleObject).replace(/"([^"\n]+)":/g, '$1:'); } return html .replace(/style="([^"]+)"/g, (_, styleString) => { const styleObj = parseStyleString(styleString); return `style={${styleObj}}`; }) .replace(/ onChange="{" \(newtext\)=""\>/gi, ' onChange={ (newtext) => ') .replace(/\<\/RichText\>/gi, '') .replace(/value="{(.*?)}"/gi, 'value={$1}') .replace(/"{attributes.(.*?)}"/gi, '{attributes.$1}'); }; const processImages = (imgTag) => { const properties = getImageProperties(imgTag); const { isBackground } = properties; if (!isBackground) { processImage({ ...properties, type: 'image' }); return; } processImage({ ...properties, type: 'background' }); }; const loopImages = ($) => { $('img').each((_index, img) => { processImages($(img)); }); }; const getHtml = ($) => { return $.html({ xml: false, decodeEntities: false }); }; const processEditImages = async (options) => { const $ = await loadHtml(options); loopImages($); return replaceImageComponents(getFixedHtml(getHtml($))); }; const getRichTextTemplate = (randomVariable, variableContent) => { return ` ><RichText tagName="span" value={attributes.${randomVariable}} default="${variableContent.trim().replace(/"/g, '&quot;')}" onChange={ (newtext) => { setAttributes({ ${randomVariable}: newtext }); }} /><`; }; const convertToRichText = (variableContent) => { const randomVariable = generateRandomVariableName('content'); setRandomAttributeContent(randomVariable, variableContent); return getRichTextTemplate(randomVariable, variableContent); }; const parseContent = (content) => { return content.replace(/>([^<]+)</g, (match, variableContent) => { const regex = /{|}|\(|\)|=>/; if (regex.test(variableContent.trim())) { return match; } if (variableContent.trim() === '') { return match; } return convertToRichText(variableContent); }); }; const getEditJsxContent = async (options) => { let content = transformFormToDynamicJSX(options.htmlContent); content = content.replaceAll(/<!--(.*?)-->/gs, ''); content = `<div className="custom-block">${content}</div>`; const processedImages = await processEditImages({ ...options, htmlContent: parseContent(content), }); return replaceSVGImages(processedImages); }; const createPanel = (values) => { if (values.attributes && values.attributes.length > 0) { panels.push(values); } }; const getSvgTemplate = (_match, group1, _group3, randomSVGVariable) => { return `<svg ${group1} dangerouslySetInnerHTML={ { __html: attributes.${randomSVGVariable} }}></svg>`; }; const replaceSVGImages = async (html) => { // Improved SVG handling: preserve nesting and avoid splitting SVG from parent const svgRegex = /(<svg[\s\S]*?<\/svg>)/gi; let result = ''; let lastIndex = 0; let match; while ((match = svgRegex.exec(html)) !== null) { const [svgBlock] = match; const start = match.index; const end = start + svgBlock.length; // Add preceding HTML result += html.slice(lastIndex, start); // Extract attributes and inner content const attrMatch = svgBlock.match(/^<svg([^>]*)>([\s\S]*?)<\/svg>$/i); if (attrMatch) { const group1 = attrMatch[1]; const group2 = attrMatch[2]; const randomSVGVariable = generateRandomVariableName('svg'); setRandomAttributeContent(randomSVGVariable, group2.replaceAll('className', 'class')); createPanel({ type: 'svg', title: 'SVG Markup', attributes: [randomSVGVariable], }); // Replace with JSX template, preserving parent context result += getSvgTemplate(svgBlock, group1, '</svg>', randomSVGVariable); } lastIndex = end; } result += html.slice(lastIndex); return result; }; const getSvgPanelTemplate = (panel) => { return ` { ( <PanelBody title="${panel.title}"> <PanelRow> <div> <TextareaControl label="SVG Content" help="Enter your SVG content..." value={ attributes.${panel.attributes} } onChange={ ( value ) => { setAttributes({ ${panel.attributes}: value }); } } /> </div> </PanelRow> </PanelBody> )} ` }; const getMediaPanelTemplate = (panel) => { const mediaAtts = panel.attributes?.[0] && panel.attributes[1] ? `${panel.attributes[0]}: media.url, ${panel.attributes[1]}: media.alt` : ''; return ` <PanelBody title="${panel.title}"> <PanelRow> <div> <MediaUpload onSelect={ (media) => { setAttributes({ ${mediaAtts} }); } } type="image" value={ attributes.${panel.attributes?.[0]} } render={({ open }) => ( <Button variant="secondary" style={{ marginBottom: "20px" }} onClick={ open }>Select Image</Button> )} /> {attributes.${panel.attributes?.[0]} && ( <img src={attributes.${panel.attributes?.[0]}} alt={attributes.${panel.attributes?.[1]}} /> )} </div> </PanelRow> </PanelBody> `; }; const getFormSettingsPanelTemplate = (panel) => { const [formIdAttr, sendEmailAttr] = panel.attributes; return ` <PanelBody title="${panel.title}" initialOpen={true}> <TextControl label="Form ID" disabled="true" value={attributes.${formIdAttr}} onChange={(val) => setAttributes({ ${formIdAttr}: val })} /> <ToggleControl label="Send Email on Submit" checked={attributes.${sendEmailAttr}} onChange={(val) => setAttributes({ ${sendEmailAttr}: val })} /> </PanelBody> `; }; const getPhpEmailData = (formIdAttr, fromAttr, toAttr, subjectAttr, messageAttr) => { return ` add_action('wp_ajax_send_email_${formIdAttr}', function() { //check_ajax_referer('wp_rest'); $post_id = $_POST['postId']; if (!$post_id) { wp_send_json_error('Missing post ID.'); } $post_data = $_POST; $post = get_post($post_id); if (!$post) { wp_send_json_error('Post not found.'); } $blocks = parse_blocks($post->post_content); $target_block = null; foreach ($blocks as $block) { if ($block['blockName'] === '${blockName}') { $target_block = $block; break; } } if (!$target_block) { wp_send_json_error('Block not found.'); } $attrs = $target_block['attrs'] ?? []; $to = sanitize_email(parse_form_placeholders_${functionSufix}($attrs['${toAttr}'] ?? '', $post_data)); $from = sanitize_email(parse_form_placeholders_${functionSufix}($attrs['${fromAttr}'] ?? '', $post_data)); $subject = sanitize_text_field(parse_form_placeholders_${functionSufix}($attrs['${subjectAttr}'] ?? '', $post_data)); $message = wp_kses_post(parse_form_placeholders_${functionSufix}($attrs['${messageAttr}'] ?? '', $post_data)); if (empty($to) || empty($from)) { wp_send_json_error('Missing email addresses.'); } $sent = wp_mail($to, $subject, $message, [ 'Content-Type: text/html; charset=UTF-8', 'From: ' . $from ]); if ($sent) { wp_send_json_success('Email sent'); } else { error_log('Failed to send. To: ' . $to . ' | From: ' . $from); wp_send_json_error('Failed to send email.'); } }); `; } const getEmailSettingsPanelTemplate = (panel) => { const [ sendEmailAttr, fromAttr, toAttr, subjectAttr, messageAttr, testFeedbackAttr, formIdAttr, ] = panel.attributes; emailTemplate += getEmailSaveTemplate(formIdAttr); phpEmailData += getPhpEmailData(formIdAttr, fromAttr, toAttr, subjectAttr, messageAttr); return ` { attributes.${sendEmailAttr} && ( <PanelBody title="${panel.title}" initialOpen={true}> <TextControl label="From" value={attributes.${fromAttr}} onChange={(val) => setAttributes({ ${fromAttr}: val })} /> <TextControl label="To" value={attributes.${toAttr}} onChange={(val) => setAttributes({ ${toAttr}: val })} /> <TextControl label="Subject" value={attributes.${subjectAttr}} onChange={(val) => setAttributes({ ${subjectAttr}: val })} /> <TextareaControl label="Message Template (HTML Supported)" help="Use {{fieldName}} to insert field values." value={attributes.${messageAttr}} onChange={(val) => setAttributes({ ${messageAttr}: val })} /> <Button variant="primary" onClick={() => { const form = document.getElementById('form-${formIdAttr}'); const inputs = form.querySelectorAll('input, select, textarea'); const body = new URLSearchParams(); inputs.forEach(input => { if (input.name && !input.disabled) { body.append(input.name, input.value); } }); body.append('action', 'send_test_email_${functionSufix}'); body.append('from', attributes?.${fromAttr} || ''); body.append('to', attributes?.${toAttr} || ''); body.append('subject', attributes?.${subjectAttr} || ''); body.append('message', attributes?.${messageAttr} || ''); fetch(vars.ajaxUrl, { method: 'POST', body }) .then(res => res.json()) .then(data => { setAttributes({ ${testFeedbackAttr}: data.success ? 'Test Email Sent!' : data.error }); }) .catch(() => { setAttributes({ ${testFeedbackAttr}: 'Failed to send test email.' }); }); }} > Send Test Email </Button> {attributes.${testFeedbackAttr} && ( <Notice status="info" isDismissible={false}> {attributes.${testFeedbackAttr}} </Notice> )} </PanelBody> )} `; }; const getEmailSaveTemplate = (formIdAttr) => { return ` document.addEventListener('DOMContentLoaded', function() { const form = document.getElementById('form-${formIdAttr}'); form.addEventListener('submit', function(event) { event.preventDefault(); const inputs = form.querySelectorAll('input, select, textarea'); const body = new URLSearchParams(); inputs.forEach(input => { if (input.name && !input.disabled) { body.append(input.name, input.value); } }); body.append('action', 'send_email_${formIdAttr}'); body.append('postId', vars.postId); fetch(vars.ajaxUrl, { method: 'POST', body }) .then(res => res.json()) .then(data => { console.log(data.success ? 'Test Email Sent!' : data.error); }) .catch(() => { console.log('Failed to send test email.'); }); form.reset() }); }); `; }; const getHiddenFieldsPanelTemplate = (panel) => { const [hiddenFieldsAttr] = panel.attributes; return ` <PanelBody title="${panel.title}" initialOpen={false}> { (attributes.${hiddenFieldsAttr} || []).map((field, index) => ( <div key={index} style={{ marginBottom: '10px', borderBottom: '1px solid #ddd', paddingBottom: '10px' }}> <TextControl label="Name" value={field.name} onChange={(val) => { const newFields = [...attributes.${hiddenFieldsAttr}]; newFields[index].name = val; setAttributes({ ${hiddenFieldsAttr}: newFields }); }} /> <TextControl label="Value" value={field.value} onChange={(val) => { const newFields = [...attributes.${hiddenFieldsAttr}]; newFields[index].value = val; setAttributes({ ${hiddenFieldsAttr}: newFields }); }} /> <Button variant="secondary" onClick={() => { const newFields = [...attributes.${hiddenFieldsAttr}]; newFields.splice(index, 1); setAttributes({ ${hiddenFieldsAttr}: newFields }); }} > Remove </Button> </div> ))} <Button variant="primary" onClick={() => { const newFields = [...(attributes.${hiddenFieldsAttr} || [])]; newFields.push({ name: '', value: '' }); setAttributes({ ${hiddenFieldsAttr}: newFields }); }} > Add Hidden Field </Button> </PanelBody> `; }; const findAndGetPanelTemplate = (panel) => { switch (panel.type) { case 'svg': return getSvgPanelTemplate(panel); case 'media': return getMediaPanelTemplate(panel); case 'formSettings': return getFormSettingsPanelTemplate(panel); case 'emailSettings': return getEmailSettingsPanelTemplate(panel); case 'hiddenFields': return getHiddenFieldsPanelTemplate(panel); default: return ''; } }; const getPanelsTemplate = () => { return panels .map((panel) => { return findAndGetPanelTemplate(panel); }) .join('\n'); }; const createPanels = () => { return ` <Panel> ${getPanelsTemplate()} </Panel>`; }; const buildSaveContent = (editContent) => { let output = replaceRichTextComponents(editContent); output = output.replaceAll('class=', 'className='); output = replaceMediaUploadComponents(output, images); return output; }; const removeHref = (match) => { return match.replace(/href="(.*?)"/, ''); }; const replaceRichText = (match, group1, _group2, group3) => { return removeHref(match) .replace(group1, '<span') .replace(group3, '</span>'); }; const processLinks = (options) => { let { htmlContent } = options; htmlContent = htmlContent ? htmlContent.replace( /(<a)[^>]*>([\s\S]*?)(<\/a>)/gim, replaceRichText ) : undefined; return { ...options, htmlContent, }; }; const getComponentAttributes = () => { return Object.entries(attributes) .map(([key, value]) => { return `${key}: { ${Object.entries(value).map(([k, v]) => `${k}: \`${v}\``).join(', ')} }`; }) .join(',\n'); }; const getEdit = async (options) => { let { htmlContent } = options; if (htmlContent) { profiler.start('getEdit'); options.htmlContent = unwrapBody(htmlContent); const postProcessLinks = p