Back to all work
Plugin · Tools · Frontend

WP2Code

A Claude Code plugin that ports a WordPress site to Laravel, Next.js, Astro or plain HTML by measuring the original instead of rebuilding it by eye. It mirrors the live site, drives headless Chrome to read getBoundingClientRect() on both the original and the port, and prints a section-by-section table of height and position deltas. Nine commands cover the loop from detecting the page builder through to deleting the mirror. Tested against a live Elementor site: 159 assets mirrored with zero references left pointing at the origin, and the site's whole design system recovered from computed styles.

Laravel Blade Tailwindcss Javascript HTML Nextjs Playwright Astro
Role
Design and development
Timeline
1 day, on top of a ten-page port
Year
2026
Status
Completed
WP2Code
01 / The overview

Porting a WordPress site to another stack normally means rebuilding the design by eye. That gets you to roughly 90% and stalls there, with nobody able to say exactly what is wrong.

WP2Code does the opposite. It serves a local mirror of the original next to the port, drives both in headless Chrome, and compares rendered geometry numerically. The output is a table: every top-level section, its height and its position on both sides, and the delta between them.

The process came out of a real job, moving a ten-page Elementor site to Laravel 13 and Tailwind 4, where nine of ten pages ended up matching the original to the pixel. The plugin generalises that process so the source can be any page builder and the target can be any stack.

The rule it encodes: never call a page done without a numeric comparison, and always compare position as well as height. Most layout bugs pass a height check.

02 / The challenge

The original process was tied to Laravel in exactly one place, and it was not the obvious one. It read the original's DOM through a same-origin iframe, which meant the mirror had to be served by the Laravel app itself. Driving a browser over the DevTools protocol instead removes that constraint entirely: two pages, measured independently, compared in Node. The mirror becomes a plain directory on a fixed port and the target can be anything.

Mirroring WordPress accurately is fiddlier than it looks. The same asset URL appears in four different encodings across HTML attributes, inline JSON, and JSON nested inside JSON. Elementor fetches bundles at runtime that never appear in the markup. Some URLs hide only inside inline JSON blobs where no attribute pattern finds them. Miss any of these and the mirror keeps quietly loading from the live site, which looks fine right up until the original goes away.

Headless measurement has its own traps, and three of them turned up during testing. Nested requestAnimationFrame never resolves under headless throttling, so the page-scroll pass hung forever. img.decode() never settles for an image whose source never resolves, and a mirrored page always has a few of those. Page builders start elements at opacity zero with a transform, and getBoundingClientRect reads the post-transform box, so animations have to be killed before anything is measured.

Then there are the failures that never announce themselves. A type token used but never defined emits no CSS, no warning, and no error. The element inherits its parent's size, and if the block is absolutely positioned every section height stays correct. On the original port that shipped at half size across four pages and survived a full geometry pass.

03 / The solution

Nine commands covering the whole loop. The plugin detects the page builder and the target stack, mirrors the site, extracts design tokens, measures pages, and gates every page on a numeric diff before it can be called finished.

The browser layer is where most of the care went. Every measurement runs through one prepare step so the original and the port are always read under identical conditions: animations killed, the full page scrolled so lazy sections lay out, fonts and images awaited on a budget rather than indefinitely, and clientWidth reported so two measurements taken at different effective widths are rejected instead of trusted.

The diff compares position as well as height. A section can be the right height and in the wrong place, which is how a collapsed margin drags a background image 100px up a page while every height check passes. There is an end-to-end self-check that serves two fixture pages whose section heights are identical and whose positions differ, and asserts the gate fails them. If that test ever passes, the gate is not doing its job.

The static audit catches the silent failures: undefined tokens, class names built by string interpolation that Tailwind's scanner cannot resolve, line-height left at the browser default on large text, horizontal padding on fixed-width boxes, and arbitrary values duplicating a token that already exists.

Targets ship as short adapter files stating six things: where components go, the component syntax, the class attribute name, loop syntax, how routes are registered, and where global CSS lives. Four are written, and anything else falls back to a generic adapter. Writing a real one is about forty lines.

Verified against a live Elementor site. The mirror pulled 159 assets in 13 seconds with zero references left pointing at the origin. The token census independently recovered the same palette, both font families and the same breakpoint pairs that the original port had derived by hand, including the one token whose absence caused the half-size bug.

FAQ

About this project

No. The mirror reads HTTP responses and the measurement reads rendered DOM, so neither cares how the HTML was produced. Elementor, Divi, Bricks, Beaver Builder, WPBakery, Oxygen, Gutenberg and hand-coded themes all go through the same loop. Gutenberg is the easiest of them, because theme.json is already a design token file. Only one thing is genuinely builder-specific, and it is a single line of config: the selector whose children are the page's top-level sections.

Laravel Blade, Next.js App Router, Astro and plain HTML have written adapters. Nuxt, Svelte, Vue, React with Vite, Next.js Pages Router and WordPress block themes use a generic adapter that explains what to work out from the project itself. The measurement half of the plugin has no opinion about the target at all, so adding a stack is about forty lines of notes rather than any new code.

There is a passthrough mode, but it is worth understanding before choosing it. On Elementor and Divi the CSS is scoped per post ID, so keeping the stylesheet means keeping the original wrapper markup too. What you end up with is a static copy of a site rather than a codebase you can maintain. Tailwind mode rebuilds from measured values and takes longer.

Node 18 or later and Google Chrome. It drives your installed Chrome through playwright-core, so there is no browser download. Dependencies install into the plugin's data directory on first session and survive plugin updates.

Two to four rounds of measure, write, diff per page is normal. The first two pages are the slowest because every section is new. After that reuse climbs sharply: on the ten-page site this came from, the services page needed one new section out of five, events one out of seven, about-us two out of eight, and the team page none out of four.

Style diffing is the biggest gap. The plugin compares geometry only, and matching nodes across two different DOM structures is real work that an unverified heuristic would do badly. Also missing: component clustering, ttf to woff2 font conversion, content extraction through the WordPress REST API, screenshot diffing, CSS coverage pruning, and Windows support.

Next project Design to HTML

Want something like this?

Start a conversation →