Back to devlogs
Laravel · Tailwindcss · Plugin

Building WP2Code: the iframe that made it Laravel-only

/ / 7 min read

A process for porting WordPress sites by measuring them, turned into a Claude Code plugin. The one line that tied it to Laravel was not the Blade templates, it was a same-origin iframe. Plus two ways a headless browser hangs forever, a mirror that kept phoning home, and a YAML bracket that silently deleted a command.

The WP2Code banner: the WordPress mark on the left, a ruled measurement arrow across the middle, and eight target framework logos on the right.
On this page

WP2Code is a Claude Code plugin that ports a WordPress site to another stack by measuring the original instead of rebuilding it by eye.

The process is not new. It came out of 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. Rebuilding by eye gets you to roughly 90% and stalls, with nobody able to say what is wrong. Comparing getBoundingClientRect() gets you inside a pixel and tells you which block is off and by how much.

What was new was making it work against any page builder and any target. That turned out to hinge on a single line I had not thought of as a constraint.

The one thing that was Laravel-only

The original loop measured the mirrored site from inside the port, like this:

const f = document.createElement("iframe");
f.src = "/_original/home";
document.body.appendChild(f);
// ...
const orig = [...f.contentDocument.querySelector(".elementor-42").children];

contentDocument is only readable when both documents share an origin. That is why the mirror had to be served by the Laravel app itself, through a route that existed only to make the comparison possible:

Route::get('/_original/{view}', fn (string $view) => view("original.$view"));

Blade partials, a temporary route, a folder of mirrored views. It reads like the Laravel-specific part is the templating. It is not. The templating is incidental. The iframe is the constraint, and everything else was built around it.

Driving a browser over the DevTools protocol removes it entirely. Open two pages, measure each independently, compare the numbers in Node:

const a = await measure(originalUrl, page.originalRoot, width);
const b = await measure(portUrl, page.portRoot, width);

Now the mirror is a plain directory served by a thirty-line static server on a fixed port, and the port under construction can be Next on 3000, Laravel on 8000, or Astro on 4321. No shared origin, no CORS, no route to delete afterwards. One change, and the whole thing stops caring what you are building.

Two ways a headless browser hangs forever

Every measurement runs through one shared 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.

Both of the waiting parts hung when I first ran it against a real Elementor page. Not slow. Hung.

The scroll pass looked reasonable:

window.scrollTo(0, y);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));

Headless Chrome throttles requestAnimationFrame when nothing is compositing, and a nested one can simply never fire. The promise never resolves and the script sits there until you kill it. Timers do not have that problem:

await new Promise((r) => setTimeout(r, 50));

The second hang was better hidden. Waiting for images to decode is the correct thing to do, because an image without intrinsic dimensions reflows the page under you:

await Promise.all([...document.images].map((img) => img.decode()));

img.decode() returns a promise that stays pending, forever, when the image source never resolves. A mirrored page always has a few of those: a lazy-loading placeholder with no real src, an image on a domain that has gone away. One of them is enough to stall the whole run.

The fix is a budget, and the interesting part is what you do when the budget expires. Silently carrying on means measuring a page that may still be mid-reflow, which produces deltas that are not your markup and cannot be debugged. So it counts what did not settle and says so:

tree · original · 1440px (client 1440px)
  3 image(s) never finished loading. Anything sized by those
  images may still be mid-reflow, so treat their heights as unreliable.

I timed each stage to find these. The page loaded in 7 seconds and the scroll pass finished in 0.7. Everything after that was the hang. Without the timing print I would have blamed the site.

The mirror that kept phoning home

Mirroring WordPress means downloading the CSS, JS and fonts, then rewriting every reference to point at the local copies. Miss one and the mirror keeps loading from the live site, which looks perfect right up until the original goes away.

The known trap is that the same URL appears in four encodings: plain in an HTML attribute, with escaped slashes inside inline JSON, that same escaped form inside an HTML-escaped attribute, and double-escaped when the JSON is nested in more JSON.

I handled all four and still had leaks. Checking the output:

https://forwarddsrpt.goonline.au/wp-includes/js/wp-emoji-loader.min.js
https://forwarddsrpt.goonline.au/wp-includes/js/wp-emoji-release.min.js

These were never rewritten because they were never downloaded, and they were never downloaded because discovery never saw them. I was matching href, src, url() and @import. Those two URLs live inside an inline JSON blob:

<script>window._wpemojiSettings = {"source":{"concatemoji":"https:\/\/site\/wp-includes\/js\/wp-emoji-release.min.js"}};</script>

No attribute, no url(). Nothing matches. Elementor hides its lottie defaults the same way.

The fix is to stop pattern-matching contexts and scan the text itself, normalising slashes first so encoding stops mattering for discovery:

const flat = text.split("\\/").join("/");
for (const m of flat.matchAll(originRe)) add(m[0], base, into);

Discovery does not care about encoding. Rewriting still does, and handles all four.

There is a second class of missing asset that no amount of text scanning finds: files fetched at runtime that never appear in the source. Elementor's webpack runtime builds bundle URLs at call time. The usual advice is to parse the chunk map out of the runtime, which works and is Elementor-specific.

The generic version is better and simpler. Serve the mirror, load each page in a browser, and record every request the mirror could not answer:

tab.on("response", (res) => {
  if (res.status() !== 404) return;
  missing.add(origin + new URL(res.url()).pathname);
});

Whatever the page actually asks for shows up as a 404 against your own server. No per-builder knowledge required.

Final run against the live site: 159 assets in 13 seconds, and zero references left pointing at the origin except the images, which stay remote on purpose.

One thing worth saying: I reported a false leak to myself before finding the real one. My verification grep was \.(css|js|woff2?) with no boundary, so it matched the .js inside default.json. The tool was right and the check was wrong. Worth remembering that a failing verification is not automatically a failing implementation.

A YAML bracket that deleted a command

Claude Code plugins declare commands as markdown with frontmatter. Mine looked like this:

---
description: Download the original site to a local directory.
argument-hint: [page-slug] [--render] [--no-sweep]
---

claude plugin validate --strict refused it:

YAML frontmatter failed to parse. At runtime this command loads with empty metadata (all frontmatter fields silently dropped).

A value starting with [ is a YAML flow sequence. [page-slug] parses as a one-item list, and then [--render] is a syntax error. The whole block fails, and at runtime that means the command loads with no description, no argument hint, and no tool restrictions, without complaining.

Quoting fixes it:

argument-hint: "[page-slug] [--render] [--no-sweep]"

I quoted all six. The lesson is not about YAML, it is that --strict in CI is worth the two minutes: this failure is invisible unless something checks for it.

The census that recovered a design system

The good part.

/wp-theme samples computed styles across every mirrored page and clusters them, rather than reading the stylesheets. Builder CSS is generated and unreadable, and the browser has already resolved it.

I ran it against the site I had originally ported by hand, where the design system had taken real time to derive. It came back with this:

colors      #f2efea  #0e0e0e  #c8c8c8  #ff2a00  #909090
families    Changa, Race Sport
size/line   16/24  18/21.6  28/33.6  23/27.6  50/60
boundaries  480, 576, 768, 992, 1025

Every colour I had named by hand. Both font families. The 23px/27.6px pair I had used as the worked example when writing the process down. And --text-28, which is the token that had been used in three places, never defined, and silently rendered every team member's name at half size across four pages for several hours.

Breakpoints needed one refinement. The raw list had twenty values including 673, 782 and 99999, which are plugin defaults rather than the site's design. A real breakpoint shows up as an adjacent pair, one rule at max-width: 767px and its counterpart at min-width: 768px, so filtering to values where n + 1 is also present leaves the real ones: 768 and 1025, exactly what I had used.

One thing I deliberately made it stop doing. It first named the most-used family --font-display, because it counted. The most-used family is body text; the display face is the rare one. So families come out numbered with usage counts and a rename me comment. A counter should not make a judgement about a design.

What I did not build

The plugin compares geometry only. That is the biggest gap, and both of the worst bugs from the original port were style bugs that geometry catches only by luck: an undefined token, and a line-height left at the browser default.

A style diff needs to match nodes across two different DOM structures, which means a heuristic: normalised text first, then image filename, then href, then position within the section. I have not tested it at scale, and a matcher that silently drops half the nodes is worse than no check at all. So it is written down as the next thing rather than shipped as a feature that looks like it works.

Also not built: component clustering, font conversion, content extraction through the WordPress REST API, screenshot diffing, and Windows support. All named in the README, because a missing feature you know about is a decision and a missing feature you find later is a bug report.

The check that matters

There is one test. It serves two fixture pages whose section heights are identical and whose positions differ by a negative margin, and asserts the gate fails them:

   #           height              top   verdict   section
   0     200 vs    200       0 vs      0   MATCH     section | A
   1     300 vs    300     200 vs    160   Δtop -40  section | B
   2     150 vs    150     500 vs    460   Δtop -40  section | C

Every height matches. A height-only comparison passes this page. That is the exact shape of the worst bug in the original port, where a collapsed margin pulled a section's background image 100px up while its height stayed correct.

If that test ever goes green, the gate has stopped doing its job.

Where it is

/plugin marketplace add Abdulkader-Safi/wp-to-code
/plugin install wp-to-code@wp-to-code

Needs Node 18 or later and Google Chrome. It drives your installed Chrome through playwright-core, so there is no browser download.

Code is on GitHub. The repository also carries the full research note: what generalises from the original process, what does not, and the list of things that silently go wrong when you port a page builder site.

FAQ

Frequently asked

Reading the original page's DOM through an iframe requires contentDocument access, which only works when both documents share an origin. That forced the mirrored site to be served by the application being built, which in turn meant the mirror had to live inside a Laravel route and a folder of Blade views. Driving two independent browser pages over the DevTools protocol and comparing the numbers in Node removes the shared-origin requirement, so the mirror becomes a plain static directory on its own port and the target can be any framework on any other port.

img.decode() returns a promise that resolves when the image is ready to paint and rejects when decoding fails, but it stays pending indefinitely when the source never resolves at all. A mirrored page reliably has a few of those: lazy-loading placeholders with no real src, or images on a domain that no longer answers. Awaiting them all with Promise.all means one such image stalls the entire measurement. Race the wait against a timeout, and report how many images never settled so a delta on an image-sized section is not mistaken for a markup problem.

Serve the mirror, load each page in a browser, and record every request that returns 404 from your own server. Anything the page fetches at runtime that you did not download shows up as a miss against the local mirror, and you can then fetch those exact paths from the origin. This replaces parsing a specific builder's bundle map, which works but only for that builder, with something that needs no per-builder knowledge.

A frontmatter value that starts with an opening square bracket is parsed as a YAML flow sequence. A hint like [page-slug] [--render] parses the first bracket group as a list and then hits a syntax error on the second. The whole frontmatter block fails to parse, and at runtime the command loads with every field silently dropped: no description, no argument hint, no tool restrictions, and no error message. Wrapping the value in quotes makes it a plain string. Running claude plugin validate with the strict flag in CI catches it.

Colours, font families, font sizes and line heights extract reliably, because the browser has already resolved them and sampling computed styles across every page gives you the full set with usage counts. Breakpoints need filtering: a page carries many media query widths from plugins, and the site's real boundaries are the ones that appear as adjacent pairs, such as a rule at max-width 767px alongside one at min-width 768px. Naming is the part that does not automate. Which family is the display face and which is body text is a judgement about the design, so the tool emits them numbered with counts and leaves the naming to a person.

Following along? Start a project.

Start a conversation →