I build a WordPress plugin that generates landing pages from a prompt. The first version worked the obvious way: user types "make the hero background darker", I send the entire page HTML to the model, the model sends back the entire page HTML, I save it.
It worked. It was also terrible.
Slow. A 900-line page means ~12k output tokens for a one-property change.
Expensive. Output tokens are the costly ones, and I was paying for a full rewrite on every tweak.
Lossy. The model would "helpfully" restructure the footer while fixing the hero. Or drop an aria-label. Or rename a class and break the CSS.
That last one is the real killer. Users don't complain about latency nearly as much as they complain about the thing they didn't ask to change.
Here's what I replaced it with.
The core idea
Don't ask the model for HTML. Ask it for operations on a DOM, then apply them yourself with a real parser.
json
[
{
"op": "setAttribute",
"selector": "#hero",
"name": "class",
"value": "hero hero--dark"
},
{
"op": "replaceInner",
"selector": "#hero h1",
"html": "Ship faster. Build slower."
},
{
"op": "remove",
"selector": "#hero .badge"
}
]
The model emits maybe 200 tokens instead of 12,000. And critically: anything it didn't mention is untouched by construction, not by good behaviour. The blast radius of a bad generation is bounded by the schema.
Applying the operations
I use Symfony's DomCrawler, which wraps DOMDocument with a sane API and CSS selector support.
bash
composer require symfony/dom-crawler symfony/css-selector
php
use Symfony\Component\DomCrawler\Crawler;
final class DomPatcher
{
private Crawler $crawler;
private \DOMDocument $doc;
public function __construct(string $html)
{
$this->doc = new \DOMDocument('1.0', 'UTF-8');
// libxml mangles UTF-8 without this, and injects
// <html><body> wrappers we have to strip later.
$prev = libxml_use_internal_errors(true);
$this->doc->loadHTML(
'<?xml encoding="UTF-8">' . $html,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($prev);
$this->crawler = new Crawler($this->doc);
}
public function apply(array $ops): void
{
foreach ($ops as $op) {
match ($op['op']) {
'setAttribute' => $this->setAttribute($op),
'replaceInner' => $this->replaceInner($op),
'remove' => $this->remove($op),
'insertAfter' => $this->insertAfter($op),
default => throw new \InvalidArgumentException(
"Unknown op: {$op['op']}"
),
};
}
}
private function setAttribute(array $op): void
{
foreach ($this->crawler->filter($op['selector']) as $node) {
/** @var \DOMElement $node */
$node->setAttribute($op['name'], $op['value']);
}
}
private function replaceInner(array $op): void
{
$fragment = $this->parseFragment($op['html']);
foreach ($this->crawler->filter($op['selector']) as $node) {
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
foreach ($fragment as $child) {
$node->appendChild($this->doc->importNode($child, true));
}
}
}
public function toHtml(): string
{
return $this->doc->saveHTML();
}
}
A few things that bit me:
filter() results are live-ish. If your operation removes nodes, iterate over a materialised array first — iterator_to_array() on the DOMNodeList — or you'll skip elements as the collection shifts under you.
loadHTML without LIBXML_HTML_NOIMPLIED wraps your fragment in . With it, you get the raw fragment but malformed input can produce surprising trees. I validate the output length against the input as a crude sanity check before persisting.
HTML5 tags emit libxml warnings.
, , custom elements — libxml's parser is HTML4. The warnings are harmless but you must suppress them or your error log fills up. If you need genuine HTML5 parsing, masterminds/html5 is the drop-in, at a real performance cost.The selector problem
The whole scheme collapses if the model can't reliably address a node. .btn:nth-child(3) is a coin flip on a page it can only partially see.
So I don't let it invent selectors. On generation, every addressable element gets a stable ID:
php
private function ensureIds(\DOMDocument $doc): void
{
$xpath = new \DOMXPath($doc);
$addressable = $xpath->query(
'//section | //header | //footer | //h1 | //h2 | //h3 '
. '| //p | //img | //a | //button | //ul | //form'
);
foreach ($addressable as $node) {
if (!$node->hasAttribute('data-pid')) {
$node->setAttribute('data-pid', $this->nextId());
}
}
}
The model only ever emits [data-pid="a7f3"] selectors. If it emits something else, I reject the operation rather than guessing. Rejection is cheap — I retry once with the error appended to the conversation, and the second attempt is nearly always valid.
Two-step retrieval
Even with operations, I was still sending the full page HTML as context. On a long page that's most of the input budget and it dilutes attention badly.
So: two calls.
Step 1 — locate. Send a skeleton: tag names, IDs, class lists, and the first ~60 characters of text content. No inline styles, no full copy, no SVG paths. A 900-line page compresses to about 40 lines.
Ship faster. Build slower.…
The page builder that doesn't…
Get started
…
Ask: which data-pids are relevant to this request? Response is a JSON array of IDs. Cheap, fast, and surprisingly accurate — locating is a much easier task than editing.
Step 2 — edit. Send only the full outerHTML of those nodes, plus the relevant CSS rules, and ask for operations.
Combined token cost on a typical edit dropped roughly 80% versus full-page regeneration, and the latency improvement is more than the numbers suggest because output tokens dominate wall-clock time.
The failure mode to watch: step 1 under-selects. The user says "make the buttons consistent" and the locator returns two of five buttons. My mitigation is to expand the selection — if a returned node has siblings sharing its class signature, include them. Not elegant, but it converted the most common complaint into a non-issue.
Storing it in WordPress
Since this is a WordPress plugin, a note on persistence, because the obvious choice is wrong.
I do not put the generated HTML in post_content. Instead:
post_meta holds separated html, css, and js, with distinct draft and published keys. Editing never touches what's live.
post_content holds the text copy only, stripped of markup.
That last decision is the one I'd defend hardest. SEO plugins, search indexing, excerpt generation, and the REST API all read post_content. If it contains a wall of
, every one of those systems gets garbage. Keeping plain prose there means the rest of the WordPress ecosystem behaves normally, for free.CSS and JS get written to files with content-hashed names:
php
$hash = substr(hash('xxh128', $css), 0, 12);
$file = "page-{$postId}-{$hash}.css";
Immutable filenames mean you can set a far-future Cache-Control and never think about invalidation again. Old files get garbage-collected on a scheduled hook.
What I'd tell you if you're building something similar
Constrain the output format, not the model's behaviour. Every hour I spent adding "do not modify unrelated sections" to a system prompt was wasted. Making unrelated modification structurally impossible took an afternoon and actually worked.
Validate before persisting, always. Parse the model's JSON, check the schema, check every selector resolves, apply to a clone, diff the node count. Reject and retry beats saving something broken.
Locating and editing are different tasks. Splitting them was the single largest win, larger than the operations schema itself.
libxml is old and grumpy but it's already installed. For a plugin that has to run on shared hosting, no-dependencies-that-need-extensions beats elegance.
The plugin is Pagora AI if you want to poke at the result — it's free and the source is on GitHub. Mostly I'm curious whether anyone has found a better answer to the step-1 under-selection problem, because mine is held together with heuristics.
Top comments (0)