Converts HTML with CSS to Elementor atomic widgets.
POST /wp-json/html-css-converter/v1/convert-html
See the Endpoints section below for all available endpoints.
Inline styles (style="...") are NOT supported. Styles must be defined in <style> tags using:
- ID selectors:
#element-id { ... } - Class selectors:
.class-name { ... }
| Parameter | Type | Default | Description |
|---|---|---|---|
html |
string | required | HTML content with <style> tags |
import_variables |
boolean | true |
Extract variables from :root in <style> tags |
import_classes |
boolean | true |
Create global classes from .class selectors |
import_images |
boolean | true |
Import external images from <img> tags and background-image CSS into WordPress media library |
update_mode |
string | "create_new" |
"create_new" or "update" for existing variables/classes |
postId |
integer | - | Insert widgets into existing post |
postTitle |
string | "Converted HTML" |
Title for auto-created post |
postStatus |
string | "draft" |
Status for auto-created post |
To log whenever the plugin adds custom_css (unsupported CSS passed through to widget settings), add to wp-config.php:
define( 'EHCC_DEBUG_CUSTOM_CSS', true );When WP_DEBUG is true, this is enabled by default. Logs appear in PHP error_log with source (e.g. Css_Converter, Widget_Styles_Integrator) and the CSS content.
{
"html": "<style>\n:root {\n --primary-color: #ff5733;\n --spacing-md: 16px;\n}\n#test {\n color: var(--primary-color);\n padding: var(--spacing-md);\n}\n</style>\n<div id=\"test\">Test</div>"
}{
"html": "<style>\n:root {\n --primary-color: #ffffff;\n --spacing-md: 16px;\n}\n.card {\n color: var(--primary-color);\n padding: var(--spacing-md);\n}\n</style>\n<div class=\"card\">Card content</div>"
}{
"html": "<style>\n:root {\n --brand-blue: #0066cc;\n --text-lg: 24px;\n}\n.heading {\n color: var(--brand-blue);\n}\n.large {\n font-size: var(--text-lg);\n}\n</style>\n<h1 class=\"heading large\">Big Blue Heading</h1>"
}{
"html": "<style>\n:root {\n --bg-dark: #1a1a1a;\n --text-light: #f5f5f5;\n --gap-sm: 8px;\n}\n.container {\n background-color: var(--bg-dark);\n padding: var(--gap-sm);\n}\n.content {\n color: var(--text-light);\n}\n</style>\n<div class=\"container\"><p class=\"content\">Nested text</p></div>"
}Inline formatting (<strong>, <em>, <s>, <u>, <sup>, <sub>, <a>, etc.) is preserved in e-paragraph, e-heading, and e-button:
{
"html": "<style>\n#content {\n color: #333;\n font-size: 16px;\n}\n</style>\n<p id=\"content\"><s>Strikethrough</s><br><strong>second line</strong><br><em>italic</em><br><u>underline</u><br><sup>superscript</sup><br><sub>subscript</sub><br><a target=\"_blank\" href=\"https://google.com\">link</a></p>"
}When span or div contain only inline content, they map directly to e-paragraph (no wrapper):
{
"html": "<span id=\"label\"><b>text</b></span>"
}Output: single e-paragraph with content normalized to <strong>text</strong>.
{
"html": "<div id=\"box\"><strong>text</strong></div>"
}Output: single e-paragraph.
{
"html": "<style>\n:root {\n --accent: #ff5733;\n --radius: 8px;\n}\n#wrapper {\n border-radius: var(--radius);\n}\n.highlight {\n background-color: var(--accent);\n}\n</style>\n<div id=\"wrapper\" class=\"highlight\">Mixed styles</div>"
}First request - creates --accent:
{
"html": "<style>\n:root {\n --accent: #ff0000;\n}\n.red-box {\n background-color: var(--accent);\n}\n</style>\n<div class=\"red-box\">Red</div>"
}Second request - creates --accent-1 and applies it correctly:
{
"html": "<style>\n:root {\n --accent: #00ff00;\n}\n.green-box {\n background-color: var(--accent);\n}\n</style>\n<div class=\"green-box\">Green</div>"
}{
"html": "<style>\n:root {\n --card-bg: #ffffff;\n --card-shadow: rgba(0,0,0,0.1);\n --card-radius: 12px;\n --card-padding: 24px;\n --title-color: #1a1a1a;\n --text-color: #666666;\n}\n.card {\n background-color: var(--card-bg);\n border-radius: var(--card-radius);\n padding: var(--card-padding);\n}\n.card-title {\n color: var(--title-color);\n font-size: 24px;\n}\n.card-text {\n color: var(--text-color);\n font-size: 16px;\n}\n</style>\n<div class=\"card\">\n <h2 class=\"card-title\">Card Title</h2>\n <p class=\"card-text\">Card description text goes here.</p>\n</div>"
}{
"html": "<img src=\"https://example.com/logo.svg\" alt=\"Logo\">",
"import_images": true
}This will:
- Download the SVG from the external URL
- Import it into WordPress media library
- Replace the external URL with the attachment ID in the widget
- Return the imported image info in
imported_imagesarray
{
"html": "<style>\n.hero {\n background-image: url('https://example.com/hero.jpg');\n width: 100%;\n height: 500px;\n}\n</style>\n<div class=\"hero\">Hero Section</div>",
"import_images": true
}Background images from CSS are also automatically imported and linked to the widget styles.
{
"html": "<style>\n:root {\n --gap: 16px;\n --item-bg: #f0f0f0;\n --item-padding: 12px;\n}\n.flex-container {\n display: flex;\n gap: var(--gap);\n flex-direction: row;\n}\n.flex-item {\n background-color: var(--item-bg);\n padding: var(--item-padding);\n}\n</style>\n<div class=\"flex-container\">\n <div class=\"flex-item\">Item 1</div>\n <div class=\"flex-item\">Item 2</div>\n <div class=\"flex-item\">Item 3</div>\n</div>"
}ID selectors with :hover and :focus are parsed and converted to Elementor style variants with meta.state.
{
"html": "<style>#btn { padding: 10px 20px; background-color: #cccccc; color: #333333; } #btn:hover { background-color: #999999; color: #ffffff; } #btn:focus { outline: 2px solid #0066cc; }</style><button id=\"btn\">Submit</button>"
}Test with curl:
curl -X POST "http://your-site.local/wp-json/html-css-converter/v1/convert-html" \
-H "Content-Type: application/json" \
-d '{"html":"<style>#btn { padding: 10px 20px; background-color: #cccccc; color: #333333; } #btn:hover { background-color: #999999; color: #ffffff; } #btn:focus { outline: 2px solid #0066cc; }</style><button id=\"btn\">Submit</button>"}'Expected: The button widget has style variants with meta.state: "hover" and meta.state: "focus" in addition to the default desktop variant.
display: grid and gap are supported as atomic props. Grid template properties (grid-template-columns, grid-template-rows, grid-template-areas, etc.) have no converter and go to custom_css. Use these payloads to verify the split.
{
"html": "<style>\n:root {\n --gap: 12px;\n --cell-bg: #e8e8e8;\n --cell-pad: 8px;\n}\n.grid {\n display: grid;\n gap: var(--gap);\n grid-template-columns: 1fr 1fr 1fr;\n grid-template-rows: auto 100px;\n}\n.cell {\n background-color: var(--cell-bg);\n padding: var(--cell-pad);\n}\n</style>\n<div class=\"grid\">\n <div class=\"cell\">A</div>\n <div class=\"cell\">B</div>\n <div class=\"cell\">C</div>\n <div class=\"cell\">D</div>\n <div class=\"cell\">E</div>\n <div class=\"cell\">F</div>\n</div>"
}Expected: container has display, gap as atomic props; grid-template-columns and grid-template-rows in custom_css. Cells have atomic background-color and padding. Example rendered output:
.elementor .grid {
column-gap: var(--gap);
display: grid;
row-gap: var(--gap);
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: auto 100px;
}
.elementor .cell {
padding-block-start: var(--cell-pad);
padding-block-end: var(--cell-pad);
padding-inline-start: var(--cell-pad);
padding-inline-end: var(--cell-pad);
background-color: var(--cell-bg);
}{
"html": "<style>\n:root {\n --gap: 8px;\n --header-bg: #333;\n --main-bg: #fff;\n --aside-bg: #f5f5f5;\n --footer-bg: #333;\n}\n.page {\n display: grid;\n gap: var(--gap);\n grid-template-columns: 1fr 200px;\n grid-template-rows: 60px 1fr 40px;\n grid-template-areas: \"header header\" \"main aside\" \"footer footer\";\n}\n.header { grid-area: header; background-color: var(--header-bg); }\n.main { grid-area: main; background-color: var(--main-bg); }\n.aside { grid-area: aside; background-color: var(--aside-bg); }\n.footer { grid-area: footer; background-color: var(--footer-bg); }\n</style>\n<div class=\"page\">\n <div class=\"header\">Header</div>\n <div class=\"main\">Main</div>\n <div class=\"aside\">Sidebar</div>\n <div class=\"footer\">Footer</div>\n</div>"
}Expected: .page has display, gap as atomic; grid-template-columns, grid-template-rows, grid-template-areas in custom_css. Area children have atomic background-color and grid-area in custom_css. Example rendered output:
.elementor .page {
column-gap: var(--gap-1);
display: grid;
row-gap: var(--gap-1);
grid-template-columns: 1fr 200px;
grid-template-rows: 60px 1fr 40px;
grid-template-areas:
"header header"
"main aside"
"footer footer";
}
.elementor .header {
background-color: var(--header-bg);
grid-area: header;
}
.elementor .main {
background-color: var(--main-bg);
grid-area: main;
}
.elementor .aside {
background-color: var(--aside-bg);
grid-area: aside;
}
.elementor .footer {
background-color: var(--footer-bg);
grid-area: footer;
}{
"html": "<style>\n:root {\n --gap: 16px;\n --card-bg: #fafafa;\n --card-padding: 16px;\n}\n.masonry {\n display: grid;\n gap: var(--gap);\n grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));\n}\n.card {\n background-color: var(--card-bg);\n padding: var(--card-padding);\n}\n</style>\n<div class=\"masonry\">\n <div class=\"card\">Card 1</div>\n <div class=\"card\">Card 2</div>\n <div class=\"card\">Card 3</div>\n</div>"
}Expected: display, gap atomic; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); in custom_css. Cards have atomic background-color and padding.
{
"html": "<style>\n:root {\n --btn-bg: #3498db;\n --btn-color: #ffffff;\n --btn-padding: 12px;\n --btn-radius: 6px;\n}\n.btn {\n background-color: var(--btn-bg);\n color: var(--btn-color);\n padding: var(--btn-padding);\n border-radius: var(--btn-radius);\n}\n</style>\n<button class=\"btn\">Click Me</button>"
}{
"html": "<style>\n:root {\n --font-xs: 12px;\n --font-sm: 14px;\n --font-md: 16px;\n --font-lg: 20px;\n --font-xl: 28px;\n --text-primary: #1a1a1a;\n --text-secondary: #666666;\n}\n.text-xl {\n font-size: var(--font-xl);\n color: var(--text-primary);\n}\n.text-lg {\n font-size: var(--font-lg);\n color: var(--text-primary);\n}\n.text-md {\n font-size: var(--font-md);\n color: var(--text-secondary);\n}\n</style>\n<div>\n <h1 class=\"text-xl\">Extra Large Heading</h1>\n <h2 class=\"text-lg\">Large Heading</h2>\n <p class=\"text-md\">Body text paragraph.</p>\n</div>"
}The converter supports CSS font-family properties with Google Fonts, system fonts, fallback chains, and generic font families. CSS keywords (inherit, initial, unset, revert) are automatically skipped.
Example: Multiple Font Family Scenarios
{
"html": "<style>\n#google-font { font-family: \"Roboto\", Arial, sans-serif; font-size: 24px; color: #333; }\n#quoted-single { font-family: 'Open Sans'; font-size: 18px; }\n#system-font { font-family: Arial; font-size: 16px; }\n#multiple-fallbacks { font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif; font-size: 20px; }\n#generic-serif { font-family: serif; font-size: 16px; }\n#generic-sans { font-family: sans-serif; font-size: 16px; }\n#generic-mono { font-family: monospace; font-size: 14px; }\n#with-other-props { font-family: \"Roboto\", sans-serif; font-size: 18px; font-weight: 600; color: #0066cc; line-height: 1.5; }\n#css-keyword { font-family: inherit; font-size: 16px; }\n#unquoted-multiword { font-family: Times New Roman, serif; font-size: 16px; }\n</style>\n<h1 id=\"google-font\">Google Font with Fallback</h1>\n<p id=\"quoted-single\">Single Quoted Font</p>\n<div id=\"system-font\">System Font (Arial)</div>\n<p id=\"multiple-fallbacks\">Multiple Fallback Fonts</p>\n<p id=\"generic-serif\">Generic Serif Font</p>\n<p id=\"generic-sans\">Generic Sans-Serif Font</p>\n<code id=\"generic-mono\">Generic Monospace Font</code>\n<div id=\"with-other-props\">Font Family with Other CSS Properties</div>\n<p id=\"css-keyword\">CSS Keyword (should skip font-family)</p>\n<p id=\"unquoted-multiword\">Unquoted Multi-word Font</p>"
}Supported formats:
- Quoted fonts:
"Roboto",'Open Sans' - Unquoted fonts:
Arial,Times New Roman - Fallback chains:
"Roboto", Arial, sans-serif - Generic families:
serif,sans-serif,monospace,cursive,fantasy - CSS keywords are skipped:
inherit,initial,unset,revert
Expected behavior:
- Font-family values are converted to Elementor atomic
font-familyproperty usingString_Prop_Type - Full fallback chains are preserved (e.g.,
"Roboto", Arial, sans-serif) - Font enqueuing is handled automatically by Elementor v4's
useStylePropResolverhook - CSS keywords like
inheritare skipped (no font-family property is added)
The converter supports @media (max-width: Xpx) queries. CSS breakpoint values are automatically matched to Elementor's breakpoint system using dynamic values from Elementor's settings (not hardcoded). The converter reads breakpoint configurations via Plugin::$instance->breakpoints->get_breakpoints_config(), so it respects any custom breakpoint values you've configured in Elementor.
Supported breakpoint formats:
@media (max-width: 1024px)→ maps to Elementortabletbreakpoint (if tablet is set to 1024px)@media (max-width: 767px)→ maps to Elementormobilebreakpoint (if mobile is set to 767px)@media screen and (max-width: 880px)→ maps to closest Elementor breakpoint within tolerance
Note: The actual pixel values depend on your Elementor breakpoint settings. Default values are typically tablet: 1024px, mobile: 767px, but these can be customized in Elementor → Settings → Style → Responsive Breakpoints.
Example: Responsive Header with ID Selectors
{
"html": "<style>\n#header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 20px 40px;\n background-color: #ffffff;\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n}\n#logo {\n font-size: 24px;\n font-weight: bold;\n color: #333333;\n}\n@media (max-width: 1024px) {\n #header {\n padding: 15px 30px;\n }\n}\n@media (max-width: 767px) {\n #header {\n flex-direction: column;\n gap: 20px;\n padding: 15px 25px;\n }\n #logo {\n font-size: 20px;\n }\n}\n</style>\n<header id=\"header\">\n <div id=\"logo\">MyBrand</div>\n <nav>Navigation</nav>\n</header>"
}Example: Responsive Hero Section
{
"html": "<style>\n#hero {\n padding: 80px 20px;\n background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n color: #ffffff;\n text-align: center;\n}\n#hero-title {\n font-size: 48px;\n font-weight: 700;\n margin-bottom: 20px;\n line-height: 1.2em;\n}\n#hero-subtitle {\n font-size: 20px;\n margin-bottom: 30px;\n opacity: 90%;\n}\n#hero-button {\n padding: 15px 40px;\n font-size: 18px;\n font-weight: 600;\n background-color: #ffffff;\n color: #667eea;\n border-radius: 5px;\n}\n@media (max-width: 1024px) {\n #hero {\n padding: 60px 20px;\n }\n #hero-title {\n font-size: 36px;\n }\n #hero-subtitle {\n font-size: 18px;\n }\n}\n@media (max-width: 767px) {\n #hero {\n padding: 40px 15px;\n }\n #hero-title {\n font-size: 32px;\n margin-bottom: 15px;\n }\n #hero-subtitle {\n font-size: 16px;\n margin-bottom: 25px;\n }\n #hero-button {\n width: 100%;\n max-width: 300px;\n font-size: 16px;\n padding: 12px 30px;\n }\n}\n</style>\n<div id=\"hero\">\n <h1 id=\"hero-title\">Welcome to Our Platform</h1>\n <p id=\"hero-subtitle\">Building amazing experiences for the modern web</p>\n <button id=\"hero-button\">Get Started</button>\n</div>"
}Example: Responsive Grid Layout with Classes
{
"html": "<style>\n:root {\n --card-bg: #ffffff;\n --card-padding: 40px;\n --card-gap: 40px;\n}\n.features {\n display: grid;\n gap: var(--card-gap);\n padding: 80px 40px;\n background-color: #f8f9fa;\n}\n.feature-card {\n background-color: var(--card-bg);\n padding: var(--card-padding);\n border-radius: 8px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n text-align: center;\n}\n.feature-icon {\n font-size: 48px;\n margin-bottom: 20px;\n}\n.feature-title {\n font-size: 24px;\n font-weight: 600;\n color: #333333;\n margin-bottom: 15px;\n}\n.feature-description {\n font-size: 16px;\n color: #666666;\n line-height: 1.6em;\n}\n@media (max-width: 1024px) {\n .features {\n gap: 35px;\n padding: 60px 30px;\n }\n}\n@media (max-width: 767px) {\n .features {\n gap: 25px;\n padding: 40px 20px;\n }\n .feature-card {\n padding: 30px 20px;\n }\n .feature-icon {\n font-size: 36px;\n }\n .feature-title {\n font-size: 20px;\n }\n .feature-description {\n font-size: 14px;\n }\n}\n</style>\n<div class=\"features\">\n <div class=\"feature-card\">\n <div class=\"feature-icon\">🚀</div>\n <h3 class=\"feature-title\">Fast Performance</h3>\n <p class=\"feature-description\">Optimized for speed and efficiency.</p>\n </div>\n <div class=\"feature-card\">\n <div class=\"feature-icon\">🔒</div>\n <h3 class=\"feature-title\">Secure & Safe</h3>\n <p class=\"feature-description\">Enterprise-grade security.</p>\n </div>\n</div>"
}How it works:
- Desktop styles (outside
@mediaqueries) become the basedesktopvariant @media (max-width: 1024px)styles map totabletvariant@media (max-width: 767px)styles map tomobilevariant- Elementor generates separate CSS files per breakpoint with proper media queries
- Styles are applied automatically based on screen size
Breakpoint matching:
- Exact matches: CSS breakpoint value exactly matches an Elementor breakpoint value → returns that breakpoint name
- Closest match: Within 200px tolerance → returns the closest Elementor breakpoint name
- Unmatched: Breakpoints that don't match any Elementor breakpoint within tolerance are skipped
Example: If your Elementor tablet breakpoint is set to 1024px and mobile to 767px:
@media (max-width: 1024px)→ exact match →tablet@media (max-width: 767px)→ exact match →mobile@media (max-width: 880px)→ closest to mobile (113px difference) →mobile@media (max-width: 1366px)→ no match within 200px tolerance → skipped
If you change Elementor breakpoints to tablet: 1200px and mobile: 768px, the matching will automatically use those new values.
Properties that have no converter, or values that a converter rejects, are stored in the style’s custom_css field and rendered as-is.
Unsupported property (no converter): e.g. vertical-align, cursor, outline, resize, overflow-wrap, list-style.
Unsupported value (converter returns null): e.g. display: table-cell (only block/flex/grid/etc. are supported), or text-shadow (converter not implemented; always goes to custom_css).
Mixed: same rule can have both atomic props and custom_css; only the unsupported parts go to custom_css.
Single payload covering all scenarios: aligned-box (vertical-align), clickable (cursor, outline), table-cell (display: table-cell), glow (text-shadow), special (resize, overflow-wrap, list-style).
{
"html": "<style>\n:root {\n --text-color: #333333;\n --box-padding: 12px;\n --bg: #f0f0f0;\n --pad: 16px;\n --pad-sm: 8px;\n --color: #111;\n --gap: 12px;\n}\n.aligned-box {\n color: var(--text-color);\n padding: var(--box-padding);\n vertical-align: middle;\n}\n.clickable {\n background-color: var(--bg);\n padding: var(--pad);\n cursor: pointer;\n outline: 2px solid blue;\n}\n.table-cell {\n padding: var(--pad-sm);\n display: table-cell;\n}\n.glow {\n color: var(--color);\n text-shadow: 0 0 10px rgba(0,0,0,0.5);\n}\n.special {\n gap: var(--gap);\n resize: both;\n overflow-wrap: break-word;\n list-style: disc inside;\n}\n</style>\n<div class=\"aligned-box\">vertical-align</div>\n<div class=\"clickable\">cursor, outline</div>\n<div class=\"table-cell\">display: table-cell</div>\n<p class=\"glow\">text-shadow</p>\n<div class=\"special\">resize, overflow-wrap, list-style</div>"
}Expected per class:
- aligned-box: atomic
color,padding;custom_css:vertical-align: middle; - clickable: atomic
background-color,padding;custom_css:cursor: pointer; outline: 2px solid blue; - table-cell: atomic
padding;custom_css:display: table-cell; - glow: atomic
color;custom_css:text-shadow: 0 0 10px rgba(0,0,0,0.5); - special: atomic
gap;custom_css:resize: both; overflow-wrap: break-word; list-style: disc inside;
Expected output when Elementor renders (atomic props as logical properties + custom_css under .elementor). Property order may vary; dimensions become padding-block-* / padding-inline-*, and gap becomes row-gap and column-gap.
.elementor .aligned-box {
color: var(--text-color);
padding-block-start: var(--box-padding);
padding-block-end: var(--box-padding);
padding-inline-start: var(--box-padding);
padding-inline-end: var(--box-padding);
vertical-align: middle;
}
.elementor .clickable {
padding-block-start: var(--pad);
padding-block-end: var(--pad);
padding-inline-start: var(--pad);
padding-inline-end: var(--pad);
background-color: var(--bg);
cursor: pointer;
outline: 2px solid blue;
}
.elementor .table-cell {
padding-block-start: var(--pad-sm);
padding-block-end: var(--pad-sm);
padding-inline-start: var(--pad-sm);
padding-inline-end: var(--pad-sm);
display: table-cell;
}
.elementor .glow {
color: var(--color);
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
.elementor .special {
column-gap: var(--gap);
row-gap: var(--gap);
resize: both;
overflow-wrap: break-word;
list-style: disc inside;
}curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/convert-html" \
-H "Content-Type: application/json" \
-d '{
"html": "<style>\n:root {\n --primary: #ff5733;\n}\n.box {\n color: var(--primary);\n}\n</style>\n<div class=\"box\">Hello</div>"
}'curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/convert-html" \
-H "Content-Type: application/json" \
-d '{
"html": "<style>.card { padding: 20px; }</style><div class=\"card\">Content</div>",
"postTitle": "My New Page",
"postStatus": "draft"
}'curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/convert-html" \
-H "Content-Type: application/json" \
-d '{
"html": "<style>.new-section { margin: 20px; }</style><div class=\"new-section\">New content</div>",
"postId": 123
}'{
"success": true,
"widgets": [...],
"imported_variables": ["--primary-color", "--spacing-md"],
"imported_classes": {
"card": {
"label": "card",
"elementor_id": "gc_abc123",
"status": "created"
}
},
"imported_images": [
{
"url": "https://example.com/image.jpg",
"id": 123
}
],
"post_id": 456,
"edit_url": "http://elementor.local/wp-admin/post.php?post=456&action=elementor"
}{
"success": false,
"error": "No supported HTML elements found"
}The response may include a warnings array when there are non-critical issues:
{
"success": true,
"widgets": [...],
"warnings": [
"Variable '--undefined-var' used but not defined",
"SVG import requires \"Enable Unfiltered File Uploads\" to be enabled in Elementor > Settings > Advanced"
]
}Common warnings:
- Undefined variables: CSS variables referenced but not defined
- SVG import permissions: When SVG images are detected but required permissions are missing (see Image Import Requirements below)
POST /wp-json/html-css-converter/v1/apply-styles-to-widget
Applies CSS styles to an existing widget in an Elementor post by converting CSS to atomic format and merging with existing widget styles.
| Parameter | Type | Required | Description |
|---|---|---|---|
postId |
integer | yes | The Elementor post/page ID |
widgetId |
string | yes | The widget ID to apply styles to |
cssString |
string | yes | CSS styles to convert and apply |
{
"success": true,
"postId": 123,
"widgetId": "abc123",
"stylesApplied": {
"color": "#ff0000",
"padding": "16px"
},
"customCss": "vertical-align: middle;"
}{
"success": false,
"error": "Widget not found"
}curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/apply-styles-to-widget" \
-H "Content-Type: application/json" \
-d '{
"postId": 123,
"widgetId": "abc123",
"cssString": "#my-widget { color: #ff0000; padding: 20px; background-color: #f0f0f0; }"
}'POST /wp-json/html-css-converter/v1/create-post-with-widget
Creates a new Elementor post/page with a single styled widget.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
postTitle |
string | yes | - | Title for the new post |
postStatus |
string | no | "draft" |
Post status ("draft", "publish", etc.) |
widgetType |
string | yes | - | Elementor widget type (e.g., "e-heading") |
widgetSettings |
object | no | {} |
Widget settings/configuration |
cssString |
string | no | "" |
CSS styles to convert and apply to the widget |
{
"success": true,
"postId": 456,
"widgetId": "def456",
"editUrl": "http://elementor.local/wp-admin/post.php?post=456&action=elementor"
}{
"success": false,
"error": "Failed to create post"
}curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/create-post-with-widget" \
-H "Content-Type: application/json" \
-d '{
"postTitle": "Styled Heading Page",
"postStatus": "draft",
"widgetType": "e-heading",
"widgetSettings": {
"title": "Hello World",
"size": "large"
},
"cssString": ".e-heading { color: #0066cc; font-size: 32px; margin-bottom: 20px; }"
}'POST /wp-json/html-css-converter/v1/add-widget-to-post
Adds a styled widget to an existing Elementor post/page.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
postId |
integer | yes | - | The Elementor post/page ID |
widgetType |
string | yes | - | Elementor widget type (e.g., "e-heading") |
widgetSettings |
object | no | {} |
Widget settings/configuration |
cssString |
string | no | "" |
CSS styles to convert and apply to the widget |
{
"success": true,
"postId": 123,
"widgetId": "ghi789"
}{
"success": false,
"error": "Failed to add widget to post"
}curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/add-widget-to-post" \
-H "Content-Type: application/json" \
-d '{
"postId": 123,
"widgetType": "e-button",
"widgetSettings": {
"text": "Click Me",
"link": {
"url": "https://example.com"
}
},
"cssString": ".e-button { background-color: #3498db; color: #ffffff; padding: 12px 24px; border-radius: 6px; }"
}'POST /wp-json/html-css-converter/v1/css-to-atomic
Converts CSS string to atomic widget properties without creating or modifying any documents.
| Parameter | Type | Required | Description |
|---|---|---|---|
cssString |
string | yes | CSS styles to convert |
{
"success": true,
"props": {
"color": "#ff0000",
"padding": "16px"
},
"customCss": "vertical-align: middle;"
}curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/css-to-atomic" \
-H "Content-Type: application/json" \
-d '{
"cssString": ".my-class { color: #ff0000; padding: 16px 24px; background-color: #f0f0f0; vertical-align: middle; }"
}'POST /wp-json/html-css-converter/v1/import-variables
Imports CSS variables from CSS string or URL into Elementor global variables.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
css |
string | no* | - | CSS string containing variable definitions |
url |
string | no* | - | URL to fetch CSS from |
update_mode |
string | no | "create_new" |
"create_new" or "update" for existing variables |
* Either css or url must be provided.
{
"success": true,
"variables": {
"primary-color": {
"name": "--primary-color",
"value": "#ff0000",
"type": "color-hex"
}
},
"created": 5,
"reused": 2,
"updated": 0,
"reactivated": 0,
"skipped_variables": [
{
"name": "--transition-speed",
"value": "0.3s"
}
]
}When some variables cannot be imported (unsupported value types), they are listed in skipped_variables so you can see exactly what was not imported.
{
"error": "No variables found in CSS",
"code": "no_variables"
}curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/import-variables" \
-H "Content-Type: application/json" \
-d '{
"css": ":root { --primary-color: #ff5733; --spacing-md: 16px; --spacing-lg: 32px; --font-size-base: 16px; }",
"update_mode": "create_new"
}'curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/import-variables" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/styles.css",
"update_mode": "update"
}'Variables are imported based on their CSS value format. The following value types are recognised and converted to Elementor global variables:
| Category | Supported formats | Examples |
|---|---|---|
| Colors | Hex, RGB, RGBA, HSL, HSLA | #ff0000, rgb(255,0,0), rgba(255,0,0,0.5), hsl(0,100%,50%), hsla(0,100%,50%,0.5) |
| Colors | Named colors | red, dodgerblue, transparent |
| Colors | color-mix() function |
color-mix(in srgb, #fff 50%, #000) |
| Sizes | Viewport and length units | 16px, 1rem, 1.5em, 50vw, 100vh, 5ch, 10vmin, 20vmax |
| Sizes | Percentages | 50%, 100% |
| Sizes | CSS math functions | calc(100% - 40px), min(100vw, 1200px), max(50vw, 300px), clamp(1rem, 2.5vw, 2rem) |
| Sizes | Opacity (name contains "opacity", value 0–1) | --opacity-dim: 0.5 |
| Sizes | Line height (name contains "line-height" or "lineheight" unitless) | --line-height-base: 1.6 |
| Fonts | Font family values | 'Inter', sans-serif, Arial, monospace |
Variables whose values do not match any of the supported formats above are not imported. The import response includes a skipped_variables array listing each variable that was not imported (name and value). Common unsupported value types:
| Unsupported type | Examples |
|---|---|
| Time and duration | 0.3s, 300ms |
| Unsupported length units | 12pt |
| Shorthand values | 1px solid #ccc, 0 2px 4px rgba(0,0,0,0.1) |
| CSS functions (non-color, non-math) | var(--other), linear-gradient(...), url(...) |
| Unitless numbers (non-opacity, non–line-height) | --z-index: 10, --scale: 2 |
| CSS keywords | inherit, initial, unset, revert |
POST /wp-json/html-css-converter/v1/import-classes
Imports CSS class definitions from CSS string or URL into Elementor Global Classes.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
css |
string | no* | - | CSS string containing class definitions |
url |
string | no* | - | URL to fetch CSS from |
update_mode |
string | no | "create_new" |
"create_new" or "update" for existing classes |
context |
string | no | "frontend" |
"frontend" or "preview" |
* Either css or url must be provided.
{
"success": true,
"classes": {
"card": {
"label": "card",
"elementor_id": "gc_abc123",
"status": "created"
}
},
"statistics": {
"detected": 10,
"converted": 8,
"registered": 8,
"skipped": 2,
"updated": 0
},
"overflow": []
}{
"error": "No classes found in CSS",
"code": "no_classes"
}curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/import-classes" \
-H "Content-Type: application/json" \
-d '{
"css": ".card { background-color: #ffffff; padding: 24px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .btn-primary { background-color: #3498db; color: #ffffff; padding: 12px 24px; border-radius: 6px; }",
"update_mode": "create_new",
"context": "frontend"
}'curl -X POST "http://elementor.local/wp-json/html-css-converter/v1/import-classes" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/components.css",
"update_mode": "update",
"context": "preview"
}'div->e-div-block(with block children) ore-paragraph(inline-only content)h1-h6->e-headingp->e-paragrapha->e-button(simple links) ore-div-block(complex content)button->e-buttonimg->e-imagespan->e-paragraph(inline-only) ore-div-block(with block children)ul,ol,li->e-div-block(layout lists only; see Known Limitations)
Inline formatting is preserved in e-paragraph, e-heading, e-button. <b> and <i> are normalized to <strong> and <em>. Allowed tags: <strong>, <em>, <s>, <u>, <sup>, <sub>, <a>, <br>, etc.
Elementor v4 (Alpha) does not yet support certain HTML/CSS features. These are documented here for clarity.
Layout lists (containing block-level elements like <div>, <section>, <button>) are converted to container widgets (e-div-block).
Semantic lists (containing only text and inline elements) are not supported and will be skipped during conversion. Elementor v4 does not have a native list widget; when list atoms are introduced, semantic lists will be supported.
<!-- Supported: Layout list -->
<ul class="flex">
<li><div class="card">...</div></li>
</ul>
<!-- Not supported: Semantic list -->
<ul>
<li>Bullet point 1</li>
<li>Bullet point 2</li>
</ul>Pseudo-element selectors are not yet supported. CSS containing pseudo-elements will be preserved in the custom_css field but will not generate atomic styling. See Development Roadmap for planned features.
Standalone WordPress plugin (not an Elementor module). Integrates via REST API and Elementor hooks.
elementor-html-css-converter/
├── elementor-html-css-converter.php
├── includes/
│ ├── class-plugin.php
│ ├── class-rest-api.php
│ ├── class-css-converter.php
│ ├── class-converter-registry.php
│ ├── interfaces/
│ ├── abstracts/
│ ├── converters/
│ └── prop-types/
| Endpoint | Purpose |
|---|---|
POST .../convert-html |
HTML + <style> → atomic widgets; optional variable/class import |
POST .../css-to-atomic |
CSS → atomic props (no document) |
POST .../apply-styles-to-widget |
Apply CSS styles to an existing widget in a post |
POST .../create-post-with-widget |
Create a new Elementor post with a styled widget |
POST .../add-widget-to-post |
Add a styled widget to an existing Elementor post |
POST .../import-classes |
CSS class definitions → Elementor Global Classes (css or url, update_mode: create_new |
POST .../import-variables |
CSS variable definitions → Elementor global variables |
GET .../breakpoints |
Elementor breakpoint config for scraper (enabled max-width breakpoints). Used when the scraper needs responsive viewport values. |
POST .../trigger-import |
Triggers external scraper workflow; passes breakpoints from Elementor config in the payload. See Scraper integration. |
When the elementor-playwright-scraper runs, it needs Elementor's breakpoint values to capture responsive styles. The flow is polling-based: WordPress POSTs to the scraper to start a scrape, receives job_id and scraper_endpoint, then polls GET {scraper_endpoint}/results/{job_id} until the result is ready. This works from local development (e.g. elementor.local) without webhooks.
-
POST .../trigger-import— Sendsbreakpointsin the payload to the scraper and returnsjob_idandscraper_endpoint. Breakpoints are fromPlugin::$instance->breakpoints->get_breakpoints_config()(enabled max-width only, sorted descending by width). -
GET .../breakpoints— Returns{ "breakpoints": [ { "name": "tablet", "width": 1024, "direction": "max" }, ... ] }. Used when the scraper runs locally or via CLI withELEMENTOR_BASE_URLset and no--breakpointsargument.
Breakpoint values match the site's Elementor → Settings → Style → Responsive Breakpoints configuration.
Selector examples (for the Import from URL modal): CSS (.hero, [aria-label="Producten"]), label:Zoeken (aria-label shorthand), xpath://a[contains(text(),"Vacatures")] for text-based selection. See scraper intelligent selectors.
- From HTML:
import_variables: true(default) extracts variables from:root(and other selectors) in<style>tags. - From request: pass raw declarations in
css_variables, e.g."--primary: #ff0000; --spacing: 16px;". - Both can be combined; value-aware deduplication applies. Undefined
var()references produce warnings only.
When import_images: true (default), external images from <img> tags and background-image CSS properties are automatically imported into the WordPress media library.
- Automatic import: External images are downloaded and added to the media library
- Duplicate detection: Checks for existing images by:
- URL (if already a local attachment)
- Elementor source hash (SHA1 of source URL)
- Filename + file size match
- Widget data update: Image URLs are replaced with WordPress attachment IDs in widget settings
- Response data: Returns
imported_imagesarray with imported image URLs and attachment IDs
Regular images (JPG, PNG, GIF, WebP, etc.):
- No special requirements - WordPress handles these by default
SVG images:
- Elementor setting: "Enable Unfiltered File Uploads" must be enabled in Elementor > Settings > Advanced
- PHP classes:
DOMDocumentandSimpleXMLElementmust be available (usually included) - User capability: User must have
manage_optionscapability OR Elementor role manager must allow JSON uploads - Mime type: SVG mime type (
image/svg+xml) must be registered in WordPressupload_mimesfilter
If SVG import requirements are not met, warnings will be included in the API response. Regular images will still import successfully.
The plugin includes a security bypass handler (Svg_Security_Bypass_Handler) that allows SVG imports to work in unauthenticated REST API contexts while still respecting Elementor's security settings.
How it works:
When the REST API endpoint is called without authentication (user ID = 0), the bypass handler:
- Checks Elementor option directly: Instead of requiring user context, it checks the
elementor_unfiltered_files_uploadoption directly from the database - Registers SVG mime type: Automatically registers the SVG mime type for REST API requests if Safe SVG plugin is active or if the option is enabled
- Allows operations conditionally: Only allows SVG imports when:
- Elementor "Enable Unfiltered File Uploads" setting is enabled
- SVG sanitizer can run (DOMDocument/SimpleXMLElement available)
- The request is a REST API request
Security considerations:
- The bypass handler does NOT override WordPress or Elementor security hooks
- It only applies to unauthenticated REST API requests
- It still requires Elementor's unfiltered uploads setting to be enabled
- SVG content is still sanitized using Elementor's sanitizer
- All security checks are centralized in
class-svg-security-bypass-handler.phpfor easy maintenance
Configuration:
To enable SVG imports for unauthenticated REST API requests:
- Enable "Enable Unfiltered File Uploads" in Elementor > Settings > Advanced
- Ensure Safe SVG plugin is active (recommended) OR add SVG mime type to your theme's
functions.php:add_filter( 'upload_mimes', function($mimes) { $mimes['svg'] = 'image/svg+xml'; return $mimes; } );
The bypass handler logic can be updated independently in includes/converters/images/class-svg-security-bypass-handler.php without modifying the main import service code.
{
"html": "<img src=\"https://example.com/image.jpg\" alt=\"Example\">",
"import_images": true
}Response includes imported images:
{
"success": true,
"widgets": [...],
"imported_images": [
{
"url": "https://example.com/image.jpg",
"id": 123
}
]
}- Style ID:
e-{widget_id}-{7_char_hex}(e.g.e-d91b1ac-2e48908). - Unsupported CSS: Properties that atomic widgets do not support (e.g.
vertical-align) are stored in the style variant’scustom_cssfield and rendered as-is. - Dimensions: Padding/margin use logical properties (
block-start,inline-end,block-end,inline-start). Implementation aligns with Elementor’s css-converter (PR #32856) where applicable.
composer install
composer test # Unit tests (fast, no WordPress)
composer test:install # One-time setup for integration tests
composer test:integration # Integration tests (needs WordPress)For detailed setup instructions, especially for Local by Flywheel:
- PHPUnit Setup Guide - Prerequisites, troubleshooting, Local by Flywheel configuration
| Command | Description |
|---|---|
composer test |
Run unit tests (~1s, no WordPress) |
composer test:unit |
Same as test |
composer test:integration |
Run integration tests (requires test:install first, needs WP+Elementor) |
composer test:all |
Run unit then integration |
composer test:performance |
Run performance baseline tests |
composer test:install |
Install WP test lib, Elementor, and symlink plugin (prompts for DB credentials) |
composer run coverage |
Generate code coverage report (requires phpdbg) |
composer mutation |
Run mutation testing (requires composer require infection/infection --ignore-platform-req=php first; PHP 8.1+) |
- docs/phpunit-test-plan.md - PHPUnit test plan and 80% coverage roadmap
- tests/phpunit/docs/test-improvement.md - Phase-by-phase improvement plan and status
- CLAUDE.md - Claude Code context file
- docs/ - Architecture overview (ARCHITECTURE.md), plans (docs/archive/planning/), API details and Elementor parity references (docs/archive/)
- docs/phpunit-test-plan.md - PHPUnit test plan and 80% coverage roadmap
- tests/phpunit/docs/test-improvement.md - Test improvement plan with phase status