UNPKG

html-to-gutenberg

Version:

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

1,589 lines (1,311 loc) 48 kB
import presetReact from '@babel/preset-react'; import { transform } from '@svgr/core'; 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 icon from 'html-screenshots'; import imageToBase64 from 'image-to-base64'; import { createRequire } from 'module'; import convert from 'node-html-to-jsx'; import path from 'path'; import { imports, images, characters } from './globals.js'; const require = createRequire(import.meta.url); const { version } = require('./package.json'); const block = async ( htmlContent, options = { name: 'My block', prefix: 'wp', category: 'common', basePath: process.cwd(), shouldSaveFiles: true, generateIconPreview: false, jsFiles: [], cssFiles: [], source: null, } ) => { const panels = []; const styles = []; const scripts = []; const attributes = {}; const formVars = {}; const { name, prefix, source } = options; let js = ''; let css = ''; let phpEmailData = ''; let emailTemplate = ''; 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 parseInlineStyle(styleString) { if (!styleString || typeof styleString !== 'string') return ''; const styleEntries = styleString .split(';') .map(rule => rule.trim()) .filter(Boolean) .map(rule => { const [property, value] = rule.split(':').map(part => part.trim()); if (!property || !value) return null; const camelCasedProperty = property.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); return [camelCasedProperty, value]; }) .filter(Boolean); const styleObject = Object.fromEntries(styleEntries); return JSON.stringify(styleObject).replace(/"([^"]+)":/g, '$1:'); } function sanitizeAndReplaceLeadingNumbers(input) { const numberWords = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; let hasReplacedFirstNumber = false; return input .toLowerCase() .replace(/[\s\-_]/g, '') .replace(/\d/g, (digit) => { if (hasReplacedFirstNumber) return digit; hasReplacedFirstNumber = true; return numberWords[parseInt(digit)] + digit; }) .replace(/^[^a-z]+/, ''); } const replaceUnderscoresSpacesAndUppercaseLetters = (input = '') => { const nonWordOrUnderscore = /\W|_/g; return input.replace(nonWordOrUnderscore, '-').toLowerCase(); }; const buildFilePath = (basePath, fileName) => { return path.join(basePath, fileName); }; const writeFile = (filePath, data) => { fs.writeFileSync(filePath, data); }; const saveFile = (fileName, contents, options) => { try { const filePath = buildFilePath(options.basePath, fileName); writeFile(filePath, contents); return contents; } catch (error) { logError(error); } }; function buildRelativeUrlRegex() { const attributes = [ 'src', 'href', 'action', 'srcset', 'poster', 'data', 'formaction' ]; const protocolsToIgnore = ['https?:', '//', 'mailto:', 'tel:', '#'].join('|'); return new RegExp( `\\b(${attributes.join('|')}|data-[a-zA-Z0-9_-]+)\\s*=\\s*(['"])(?!${protocolsToIgnore})([^'"]+)\\2`, 'gi' ); } function replaceRelativeUrls(html, replacer) { const regex = buildRelativeUrlRegex(); return html.replace(regex, (_match, attribute, quote, url) => { const updatedUrl = replacer(url); return `${attribute}=${quote}${updatedUrl}${quote}`; }); } function getRelativeUrlCssRegex() { const ignoredProtocols = ['https?:', '//', 'data:', 'mailto:', 'tel:', '#'].join('|'); return new RegExp( `url\\(\\s*(['"]?)(?!${ignoredProtocols})([^'")]+)\\1\\s*\\)`, 'gi' ); } function replaceRelativeUrlsInCss(css, replacer) { const regex = getRelativeUrlCssRegex(); return css.replace(regex, (_match, quote, url) => { const updatedUrl = replacer(url); return `url(${quote}${updatedUrl}${quote})`; }); } function resolveUrl(relativePath, baseUrl) { return new URL(relativePath, baseUrl).href; } function replaceRelativeUrlsInHtml(html, baseUrl) { return replaceRelativeUrls(html, (url) => resolveUrl(url, baseUrl)); } function replaceRelativeUrlsInCssWithBase(css, cssFileUrl) { return replaceRelativeUrlsInCss(css, (url) => resolveUrl(url, cssFileUrl)); } 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 = (input = '') => { if (!input) return ''; return input .replaceAll('-', '_') .replaceAll(' ', '_') .toLowerCase(); }; const newName = replaceUnderscoresSpacesAndUppercaseLetters(name); const blockName = `${sanitizeAndReplaceLeadingNumbers(replaceUnderscoresSpacesAndUppercaseLetters(prefix))}/${sanitizeAndReplaceLeadingNumbers(replaceUnderscoresSpacesAndUppercaseLetters(name))}`; const blockNameHandle = `${prefix}-${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)} ); } } `; }; function transformBlockFile(code) { const transformOptions = { presets: [[presetReact, { pragma: 'wp.element.createElement' }]], filename: 'block.js', }; return babel.transformSync(code, transformOptions); } 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; } const scopedCssFrontend = scopeCss(css, `.wp-block-${sanitizeAndReplaceLeadingNumbers(replaceUnderscoresSpacesAndUppercaseLetters(prefix))}-${sanitizeAndReplaceLeadingNumbers(replaceUnderscoresSpacesAndUppercaseLetters(name))}`); const editorStyleFile = scopeCss(css, `[data-type="${blockName}"]`); const scriptFile = js; let blockCode = await getBlock(htmlContent, options); blockCode = blockCode .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') .replace(/<link[^>]*?>/gi, ''); const indexFile = getPhp(options); const blockFile = transformBlockFile(blockCode).code .replace(/name: \"\{field.name\}\"/g, 'name: field.name') .replace(/key: \"\{index\}\"/g, 'key: index') if (shouldSaveFiles) { saveFile('style.css', scopedCssFrontend, options); saveFile('editor.css', editorStyleFile, options); saveFile('scripts.js', `${scriptFile}\n\n${emailTemplate}`, options); saveFile('index.php', indexFile, options); saveFile('block.js', blockFile, options); saveFile('remote-loader.js', '', options); } return { 'style.css': scopedCssFrontend, 'editor.css': editorStyleFile, 'scripts.js': scriptFile, 'index.php': indexFile, 'block.js': blockFile, } }; 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', 6); const setRandomAttributeContent = (randomVariableName, content) => { let newContent = content; const isArray = Array.isArray(content); if (typeof content === 'string') { newContent = content.replace(/(?:<[^>]*>|[^<"]*")|"/g, (match) => { if (match === '"') { return '&quot;'; } return match; }) } attributes[randomVariableName] = { type: isArray ? 'array' : 'string', default: newContent }; }; 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 newHtml = await extractAssets(htmlContent, { basePath, saveFile: true, verbose: false, }); 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 = prefix ? replaceUnderscoresSpacesAndUppercaseLetters(prefix) : 'wp'; const randomVariable = generateRandomVariableName(`${type}${newPrefix}`); attributes[randomVariable] = { attribute, type: 'string', selector: 'img', default: attribute === 'alt' ? imgAlt : `var.url+'${imgSrc}'`, }; 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) => { const onChangeRegex = / onChange="{" \(newtext\)=""\>/gi; const closeRichTextRegex = /<\/RichText>/gi; const valueBindingRegex = /value="{(.*?)}"/gi; const attributeBindingRegex = /"{attributes\.(.*?)}"/gi; const fixOnChange = ' onChange={ (newtext) => '; const fixValueBinding = 'value={$1}'; const fixAttributeBinding = '{attributes.$1}'; return html .replace(onChangeRegex, fixOnChange) .replace(closeRichTextRegex, '') .replace(valueBindingRegex, fixValueBinding) .replace(attributeBindingRegex, fixAttributeBinding); }; const processImages = (imgTag) => { const properties = getImageProperties(imgTag); const type = properties.isBackground ? 'background' : 'image'; processImage({ ...properties, type }); }; 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 containsCodeSyntax = (text) => { const codePattern = /{|}|\(|\)|=>/; return codePattern.test(text.trim()); }; const isEmptyText = (text) => { return text.trim() === ''; }; const parseContent = (content) => { return content.replace(/>([^<]+)</g, (match, text) => { const hasCodeSyntax = containsCodeSyntax(text); const isEmpty = isEmptyText(text); if (hasCodeSyntax || isEmpty) { return match; } return convertToRichText(text); }); }; const removeHtmlComments = (html) => { return html.replaceAll(/<!--(.*?)-->/gs, ''); }; const wrapWithContainer = (html) => { return `<div className="custom-block">${html}</div>`; }; const getEditJsxContent = async (options) => { const dynamicContent = transformFormToDynamicJSX(htmlContent); const withoutComments = removeHtmlComments(dynamicContent); const wrappedContent = wrapWithContainer(withoutComments); const parsedContent = convert(parseContent(wrappedContent)); return await processEditImages({ ...options, htmlContent: parsedContent, }); }; const hasAttributes = (panel) => { return Array.isArray(panel.attributes) && panel.attributes.length > 0; }; const createPanel = (panel) => { if (!hasAttributes(panel)) return; panels.push(panel); }; const getSvgTemplate = (_match, attributesString, closingTag, variableName) => { return ` <svg ${attributesString} dangerouslySetInnerHTML={{ __html: attributes.${variableName} }} > ${closingTag} `; }; const findSVGMatches = (html) => { const svgRegex = /<\s*svg\b((?:[^>'"]|"[^"]*"|'[^']*')*)>(\s*(?:[^<]|<(?!\/svg\s*>))*)(<\/\s*svg\s*>)/gim; return [...html.matchAll(svgRegex)]; }; const parseSVGMatch = (match) => { const [fullSvg, attributes, content, closingTag] = match; const start = match.index; const end = start + fullSvg.length; return { fullSvg, attributes, content, closingTag, start, end }; }; const hasContent = (content) => content.trim() !== ''; const handleSVGContent = (content) => { const variableName = generateRandomVariableName('svg'); const cleanedContent = content.replaceAll('className', 'class'); setRandomAttributeContent(variableName, cleanedContent); createPanel({ type: 'svg', title: 'SVG Markup', attributes: [variableName], }); return variableName; }; const transformSVG = async (fullSvg, attributes, closingTag, variableName) => { const template = getSvgTemplate(fullSvg, attributes, closingTag, variableName); return await transform(template, { jsxRuntime: 'classic' }); }; const replaceSVGImages = async (html) => { const matches = findSVGMatches(html); let output = ''; let cursor = 0; for (const match of matches) { const { fullSvg, attributes, content, closingTag, start, end } = parseSVGMatch(match); output += html.slice(cursor, start); if (hasContent(content)) { const variableName = handleSVGContent(content); const transformed = await transformSVG( fullSvg, attributes, closingTag, variableName ); output += transformed; } else { output += fullSvg; } cursor = end; } output += html.slice(cursor); return output; }; const getSvgPanelTemplate = (panel) => { return panel.attributes && attributes[panel.attributes] ? ` { ( <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 panel.attributes && panel.attributes[0] && attributes[panel.attributes[0]] ? ` <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 += sendEmailAttr ? 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) => { return editContent.replace( /<RichText((.|\n)*?)value=\{(.*?)\}((.|\n)*?)\/>/gi, '<RichText.Content value={$3} />' ) .replace(/className=/gi, 'class=') .replace( /<MediaUpload\b[^>]*>([\s\S]*?(<img\b[^>]*>*\/>)[\s\S]*?)\/>/g, (_match, _attributes, img) => { return img.replace(/onClick={[^}]+}\s*/, ''); } ); }; 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; htmlContent = unwrapAnchor(htmlContent); return { ...options, htmlContent, }; }; const mergeAttributes = (attrString) => { const attrRegex = /(\S+)=["'](.*?)["']/g; const attrs = {}; let match; while ((match = attrRegex.exec(attrString)) !== null) { attrs[match[1]] = match[2]; } return attrs; }; const unwrapAnchor = (htmlContent) => { return htmlContent.replace( /<span([^>]*)>\s*<a([^>]*)>(.*?)<\/a>\s*<\/span>/gi, (_, spanAttrs, anchorAttrs, content) => { const spanAttributes = mergeAttributes(spanAttrs); const anchorAttributes = mergeAttributes(anchorAttrs); const allAttributes = { ...spanAttributes, ...anchorAttributes }; const attrString = Object.entries(allAttributes) .map(([key, value]) => `${key}="${value}"`) .join(' '); return `<a ${attrString}>${content}</a>`; } ); }; const getComponentAttributes = () => { return JSON.stringify(attributes, null, 2); }; const getEdit = async (options) => { let { htmlContent } = options; if (!htmlContent) { return ''; } const processedOptions = processLinks(options); const jsxContent = await getEditJsxContent(processedOptions); const replacedContent = jsxContent .replace(/<article\b([^>]*)>/gi, '<div$1>') .replace(/<\/article>/gi, '</div>') .replaceAll('../', '') .replace(/style="([^"]+)"/g, (_, styleString) => { const styleObj = parseInlineStyle(styleString); return `style={${styleObj}}`; }); return await replaceSVGImages(replacedContent); }; const parseBlockAttributes = () => { const attributes = getComponentAttributes(); const jsonString = JSON.stringify(attributes, null, 2); return jsonString .replace(/"var.url\+\'(.*?)\'(.*?)"/g, "vars.url+'$1'$2") .replace(/var(.*?).url\+'(http.*?)'/g, 'http$2'); }; const fixDangerouslySetInnerHTML = (edit) => { const pattern = /dangerouslySetInnerHTML="{" {="" __html:="" (.*?)="" }}=""/gm; const replacement = 'dangerouslySetInnerHTML={{ __html: $1 }}'; return edit.replace(pattern, replacement); }; const getBlock = async (htmlContent, settings) => { let { name, category, generateIconPreview, basePath, cssFiles = [], jsFiles = [], } = settings; let iconPreview = "'shield'"; let edit = await getEdit(settings); edit = fixDangerouslySetInnerHTML(edit); const save = buildSaveContent(edit); const blockPanels = createPanels(); const blockAttributes = parseBlockAttributes(); if (generateIconPreview) { try { await icon(htmlContent, { basePath, cssFiles, jsFiles }); iconPreview = `(<img src="data:image/jpeg;base64,${await imageToBase64( path.join(basePath, 'preview.jpeg') )}" />)`; } catch (error) { console.log(`There was an error generating preview. ${error.message}`); } } const output = ` (function () { ${imports} registerBlockType('${blockName}', { title: '${name}', icon: ${iconPreview}, category: '${category}', attributes: ${blockAttributes}, edit(props) { const { attributes, setAttributes } = props; return ( <div> <InspectorControls> ${blockPanels} </InspectorControls> ${edit} </div> ); }, save(props) { const { attributes } = props; return ( ${save} ); }, }); })();`; if (generateIconPreview) { return output.replace(/icon: \s * (')([^']*)(')/, 'icon: $2'); } return output; }; const setupVariables = async (htmlContent, options) => { const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi; const linkRegex = /<link\s+[^>]*href=["']([^"']+)["'][^>]*>/gi; let match; htmlContent = htmlContent.replace(styleRegex, (_fullMatch, cssContent) => { styles.push({ type: 'inline', content: cssContent.trim() }); return ''; }); const fetchCssPromises = []; while ((match = linkRegex.exec(htmlContent)) !== null) { const url = match[1]; const fetchCssPromise = fetch(url) .then((response) => response.text()) .then((css) => styles.push({ type: 'external', content: css })) .catch(() => console.warn(`Failed to fetch: ${url}`)); fetchCssPromises.push(fetchCssPromise); } htmlContent = htmlContent.replace(linkRegex, ''); await Promise.all(fetchCssPromises); css += styles.map((style) => { return `${style.content}`; }).join('\n'); const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/gi; const scriptSrcRegex = /<script\s+[^>]*src=["']([^"']+)["'][^>]*>\s*<\/script>/gi; let jsMatch; htmlContent = htmlContent.replace(scriptRegex, (_fullMatch, jsContent) => { if (jsContent.trim()) { scripts.push({ type: 'inline', content: jsContent }); } return ''; }); const fetchJsPromises = []; while ((jsMatch = scriptSrcRegex.exec(htmlContent)) !== null) { const url = jsMatch[1]; const fetchJsPromise = fetch(url) .then((response) => response.text()) .then((js) => scripts.push({ type: 'external', content: js })) .catch(() => console.warn(`Failed to fetch script: ${url}`)); fetchJsPromises.push(fetchJsPromise); } htmlContent = htmlContent.replace(scriptSrcRegex, ''); await Promise.all(fetchJsPromises); js += scripts.map((script) => script.content).join('\n'); let { basePath = process.cwd(), cssFiles = [], jsFiles = [], name = 'My block', } = options; const newDir = path.join(basePath, replaceUnderscoresSpacesAndUppercaseLetters(name)); try { fs.mkdirSync(newDir, { recursive: true }); return { ...options, jsFiles, cssFiles, htmlContent, basePath: newDir, }; } catch (error) { logError(error); } }; if (source) { htmlContent = replaceRelativeUrlsInHtml(htmlContent, source); htmlContent = replaceRelativeUrlsInCssWithBase(htmlContent, source); } return saveFiles(await setupVariables(htmlContent, options)); }; export default block;