html2elementor
Paste HTML+CSS, get a JSON payload you can drop into _elementor_data. A small, free, open-source HTML → Elementor converter that runs locally.
When to use this skill
Use whenever the user has HTML on one side and wants Elementor on the other. Triggering phrases include (but aren't limited to):
- "convert this HTML to Elementor"
- "import this landing page into WordPress"
- "recreate this design as an Elementor page"
- "turn my mockup into Elementor"
- "bring this Tailwind page into WordPress"
- "I have HTML from [tool X], make it Elementor"
- "Elementor JSON from this markup"
Also use it implicitly — the user pastes or attaches HTML and asks "make this work in Elementor" without naming a tool.
Don't use for:
- Editing pages that are already Elementor (use WP-CLI / REST API directly).
- Plain WordPress pages with no Elementor (just
wp_insert_postwithpost_content). - Figma / Sketch / design files — this converts markup, not design-tool exports. If the user has a Figma file, suggest they first export to HTML with a tool like Figma-to-HTML or Anima, then run that output through this skill.
The four-step flow
Prepare → convert → verify → import. The first three are local and identical regardless of the user's WordPress setup. The fourth depends on how they run WordPress.
Step 0 — Prepare the input
- External stylesheets. If the HTML uses
<link rel="stylesheet" href="styles.css">, inline the CSS into a<style>block before running the converter. The parser reads<style>blocks and inlinestyle="", not external files. Inlining preserves the cascade exactly. - Relative image paths. If
<img src="assets/foo.png">points to local files on disk, run the converter with--upload(see Step 3). Keep the input file next to theassets/directory — the uploader resolves relative paths against the HTML file's own directory. - Pasted HTML. If the user pasted raw HTML, write it to a temp file (e.g.
/tmp/input.html) so the converter has a real path to anchor relative URLs and so error messages make sense.
Step 1 — Convert
Run from the installed skill directory (typically ~/.claude/skills/html2elementor or ~/.openclaw/skills/html2elementor):
.venv/bin/python3 -m html2elementor path/to/input.html -o /tmp/layout.json
Add --upload when the HTML references images (either absolute URLs or relative paths) and the user wants them pulled into the WP media library automatically:
.venv/bin/python3 -m html2elementor path/to/input.html --upload -o /tmp/layout.json
This produces two files:
/tmp/layout.json— the_elementor_datapayload (a list of top-level containers, each with nested widgets)./tmp/layout.kit.json— custom_colors and custom_typography globals. Widgets in the layout reference these viaglobals/colors?id=...; without merging this into the active kit the page renders with missing colors and fonts.
Step 2 — Verify
.venv/bin/python3 -m html2elementor.verify path/to/input.html /tmp/layout.json
The verifier walks the source HTML node-by-node and checks that each emitted widget has matching color, font-size, spacing, and max-width. Zero issues means the CSS cascade was resolved correctly and faithfully typed through. It does not guarantee pixel-perfect render — that needs a screenshot diff — but it catches the common failure mode (silent layout drift) cheaply.
Share the issues list with the user verbatim before importing. Each mismatch is actionable.
Step 3 — Import
This step depends on the user's WordPress setup, so ask them how they want to import before running any commands. Common setups and the right approach:
| Setup | How to import |
|---|---|
| Local WP (MAMP, Local by Flywheel, Laravel Valet) | wp-cli via terminal |
| Docker sandbox | docker compose exec wp wp eval ... |
| Staging on a managed host | SSH + wp-cli, or the REST API |
| Production | REST API or Elementor's template-library import |
Whatever the transport, two invariants always apply:
-
Merge
.kit.jsoncustom globals into the active kit's_elementor_page_settingspost meta. Look up the active kit ID withget_option("elementor_active_kit"), then append each entry from.kit.json'scustom_colorsandcustom_typography(dedupe by_id). Also copy scalar site settings when present:body_background_background("classic") andbody_background_color(hex from the HTML's bodybackgroundCSS). Skipping the custom arrays makes widgets render with default colors/fonts; skipping the body scalars leaves the site on the Elementor default white instead of the source page's background. -
Flush Elementor's CSS cache after updating
_elementor_data:wp elementor flush_css --allow-root. Elementor builds per-post CSS files underwp-content/uploads/elementor/css/and serves those instead of reading settings live. Without flushing, visual changes won't appear on next page load.
If the user reports "my page looks empty" or "colors are wrong after import", one of those two invariants was missed.
Internal links still point at the source HTML filenames. The converter preserves href values verbatim — href="pricing.html" stays href="pricing.html". After import, rewrite those to the WP page slugs before flushing CSS. Safest approach: preprocess the JSON files on disk with sed:
sed -i '' \
-e 's|"docs/index\.html"|"/dudaster-docs/"|g' \
-e 's|"index\.html"|"/dudaster-index/"|g' \
-e 's|"modules\.html"|"/dudaster-modules/"|g' \
-e 's|"pricing\.html"|"/dudaster-pricing/"|g' \
page.json
Process the most specific paths first (docs/index.html before index.html) so short names don't clobber longer ones. Do not try this with regex in PHP via update_post_meta — a bad pattern returning null will silently wipe _elementor_data on every matched post. Rewrite on disk, then re-import.
Kit bloat. Every re-import merges new custom_colors / custom_typography IDs into the kit (dedupe by _id) but never removes orphans from intermediate runs. After many iterations the kit accumulates hundreds of unused entries. Garbage-collect by scanning every post's _elementor_data for referenced globals/colors?id= + globals/typography?id= IDs and filtering the kit to just those. See playsand/cleanup_kit.sh in this repo for a reference implementation — safe to run anytime, only prunes IDs no post references, flags orphans where a post references a missing ID.
Step 4 (optional) — Visual verify
After import, screenshot both the source HTML and the rendered Elementor page at the same viewport (1440×auto for desktop). Compare side-by-side. The verifier catches semantic drift but some Elementor quirks (see below) only show in a real browser.
What the converter knows (non-obvious patterns)
The full reference is in README.md. These are the patterns that broke in practice and were hardened:
- CSS custom properties (
--color-brand,--font-sans) are resolved from:root/html/body, substituted post-cascade, then shorthand-re-expanded sobackground: var(--x)populatesbackground-colorcorrectly. - Mixed inline content (
<div>Paper<span>fold</span></div>) is emitted as a single widget with<span style="color:#xxx">…</span>inline HTML — never split into two widgets or layout drifts. - Circular avatars (div with bg +
border-radius:50%+ fixed px size + short text) become a styled inner container wrapping a heading — headings alone can't hold width in a flex row. - Agenda / schedule slots (flex row with fixed-width label + content) are emitted as a single text-editor with inline absolute-positioned label. Elementor row containers break fixed+grow widths (see quirks below), so we use inline HTML to sidestep.
- Split hero (text column + image column) — any
<img>,<svg>,<picture>in a card counts as content for grid detection, so