Skip to content

ADR-0012 — Automated Wix→WooCommerce replatform lane (URL in, store out)

Status: Proposed (draft 2026-07-15 — written the same day the manual dry-run shipped) Date: 2026-07-15 Deciders: Cathal Dempsey Related: ADR-0009 (target output architecture — this is a separate destination lane, not a change to the Astro lane), the wordpress repo (crawler + Elementor JSON deploy, Feb–Mar 2026), the wix-product loader (product/category translation between Ecwid/Wix/Woo/Shopify).

Context

The forcing event

bmpartsandtools.ie (Wix chrome + embedded Ecwid store, 19 pages, ~750 products, 258 categories) was replatformed onto WordPress/WooCommerce/Elementor Pro in one working day (2026-07-15), manually orchestrated in a single Claude Code session. End state: branded header/footer/homepage with tabbed FAQ, dropdown nav with dynamic active states, filtered category archives (price + category sidebar), variable products with working options, category images and descriptions recovered from the Ecwid API, product JSON-LD, all nav URLs verified 200.

Figma was skipped entirely. The historical "site → Figma → build" leg was never automated — Figma added a lossy manual round-trip and no information the crawler doesn't already measure (tokens, structure, bounding boxes, screenshots). Figma remains the surface for redesigns; for 1:1 replatforms it is out of the loop.

What the day actually decomposed into

Roughly 80% of the work was deterministic script execution using tooling that already existed (wordpress/crawler + generated deploy.php + wp-cli over SSH). The remaining 20% was judgment: a visual polish pass, and seven escalation-grade platform bugs none of which were visible in advance and all of which are now captured as recipes (memory: reference_wp_elementor_deploy_gotchas.md — eval-file long-line no-op, Elementor Pro 4 document-type rename, widget renames + __dynamic__ title bindings, condition-slug rename, Woo JSON-LD suppression on theme-builder templates, Wix CDN full-res URL surgery, Ecwid public token harvesting).

Products were handled by the separately-owned wix-product loader; coordination converged on one contract: the loader reproduces Ecwid's tree by id/parentId with exact names; the replatform side enriches idempotently on top (fill-empties-only importers for category images/descriptions, run-time URL resolution by exact name — never cached slugs).

Decision

Build an automated lane: drop in a URL → pick destination → supply credentials → crawl → deploy → feed → iterate → gated handover. Orchestrated by Claude Code headless initially.

Job spec (the input)

{
  "source_url": "https://www.example.ie",
  "destination": { "type": "woocommerce", "ssh": "host-alias", "wp_path": "/var/www/siteN",
                   "base_url": "https://..." },
  "products":    { "loader": "wix-product", "wc_keys": "auto-generate" },
  "fidelity_target": 0.9,
  "max_iterations": 3
}

Stages

# Stage Nature Notes
1 Detect + crawl Script tag-scanner platform detect; harvest Ecwid public_… token from page source; crawl (3-viewport screenshots, assets, tokens, DOM structure); intercept embedded-app APIs (FAQ server, Ecwid initial-data) for content invisible to DOM crawls
2 Provision target Script verify WP/plugins/theme, file perms, generate WC REST keys, DB backup
3 Structural deploy Script generated deploy.php via wp eval 'include …' (NEVER eval-file); child theme, kit, header/footer/pages, Woo archive + single-product templates with the Pro-4 fixes baked in
4 Products + enrichment Loader + script wix-product push (owner-run or API-triggered); then the enrichment chain: category images → descriptions → nav rebuild → link/thumbnail fixups. All idempotent; triggered by a stability watcher (N identical product-count polls above a floor)
5 Polish iteration Models see Iteration below
6 Gate Script + model deterministic verification matrix (URL matrix 200s, schema present, variations render, filters compose, add-to-cart enabled) + evaluator verdict and punch list

Loop taxonomy — and why loops don't have to eat tokens

The day's work was four different loop types with different machinery. Naming them matters because three of the four consume zero model tokens, and the one that does only converges if its prompt contract produces closeable claims rather than open-ended "improve this":

Loop Iterates over Runs on Stop condition Token cost
1. Fix-verify (inner) one surface's visual/functional deltas checker + fixer models deltas empty, or budget the only model spend; bounded below
2. Site sweep (middle) whole-site verification matrix curl + wp-cli all checks pass zero
3. External convergence someone else's process (product feed, DNS) stability watcher → idempotent enrichment N identical polls above a floor zero
4. Playbook accretion escalated defects across sites written once at root-cause time never (it's the asset) amortizes to negative — each entry deletes future iterations

A loop burns tokens when its exit test is a vibe. The guards that keep loop 1 cheap:

  • The checker outputs structured deltas (id, surface, claim, severity, verify-method) — each one is independently closeable, so "done" is a set-emptiness test, not a model's opinion.
  • Dedupe against everything seen (including wontfix), not just what's open — otherwise accepted differences re-enter every round and the loop oscillates at full price.
  • Two failed fix rounds ⇒ stop retrying, escalate for root-cause — retries are where token budgets die; every one of today's seven platform bugs was un-retryable by construction.
  • Hard budgets (rounds, tokens, wall clock); exhaustion ships a punch list, not more rounds.

Iteration (stage 5) — the fix-verify loop in detail

  • render → screenshot → check → structured deltas → fix → redeploy → re-check, per surface (header, homepage, archive, product page).
  • The checker and the fixer are different models and the fixer never closes its own delta — a delta closes only when the next check or a deterministic verifier confirms it. (Forcing example: the archive template "deployed" three times while silently not rendering; only an independent elementor-14818-in-HTML check caught it. Kin to ADR-0011's lesson: don't let the thing being graded hold the pen.)
  • Deltas that survive two fix rounds escalate to the heavy tier for root-cause diagnosis — all seven of today's platform bugs were this shape; cheap retries would never have opened the plugin source.
  • Budgets are hard stops (max rounds, token ceiling, wall clock). Exhaustion ships the punch list as "known gaps" for human review — the loop degrades to a report, never to silent failure.
  • State lives on disk per job (state.json, deltas.json); each iteration is a fresh headless invocation reading state — no long-lived context accumulating stale beliefs.

Model routing

Role Now Later
Orchestrator / builder (fix passes) Claude Code (Opus-class) open-model slot (GLM-class — 4.2/5.x, whichever is current at swap time) — lowest-risk swap because the role consumes a playbook + structured deltas, not open-ended judgment; open weights also buy cost-per-iteration at portfolio scale and self-hosting for client data
Visual checker (every round) Sonnet-class vision — outputs structured deltas only, never fixes same
Escalation + final gate Opus 4.8 same
Stages 1–4, 6 matrix no model no model

The compounding asset: the playbook

Every escalated defect ends as a symptom → root cause → recipe entry, written at resolution time by the tier that found the root cause. The playbook is loaded into the builder's context at job start. Expected shape of the cost curve: ~2 escalation-grade problems per new source→destination pair, near-zero on repeats — so iteration budget is front-loaded on the first site of each pair.

Where it runs

replatform-dashboard already has the skeleton: job queue + API (crawler/server.js, lib/job-queue.js) and the EC2. Dashboard form posts the job spec; the queue runs stages; the polish loop is a spawned headless session with budget caps.

First milestone (deliberately not full autonomy)

"One command, one site; a human reviews the evaluator's punch list before DNS." Today's site cost one day including all discovery; the pipelined version of the same site is plausibly 1–2 hours wall-clock, dominated by crawl + product feed.

Concrete first steps: 1. Extract wordpress/scripts/bmparts/* into a parametrized toolkit (site-specifics — IDs, palette, URLs — hoisted into a config block; they are ~80% generic already). 2. Define the job-spec JSON and per-stage report JSON. 3. Wire stages 1–4 + 6-matrix into the dashboard queue (no models yet — this alone is most of the wall-clock win). 4. Add the checker/fixer loop for stage 5 last, seeded with the gotchas playbook.

Build progress

The lane now has a repo: DCathal/replatform-woo (private), local ~/replatform-woo. This ADR stays the design source of truth; that repo is its implementation, and carries the playbook as docs/playbook.md.

# First-milestone step Status
1 Parametrize wordpress/scripts/bmparts/* into a toolkit Done 2026-07-16 — see below
2 Job-spec + per-stage report JSON Planned (next)
3 Wire stages 1–4 + the 6-matrix into the dashboard queue Planned
4 Checker/fixer loop for stage 5, seeded with the playbook Planned

Step 1 (2026-07-16). The chain ported into replatform-woo/scripts/bmparts/ and lost every per-site literal: Ecwid store id + public token, a nine-role palette, the brand name, and — the one that mattered — six hardcoded Elementor post ids. All now read from a gitignored config/<site>.json, except the ids, which resolve at run time (below). Remaining site-specific: the $cards label→category map in post-feed-fixups.php, which is content, not identity — whether card mappings come from the crawl or the job spec is a step-2 question, deliberately not guessed at here.

Each change was proven, not assumed: a reverse-substitution check re-derives the pristine originals byte-for-byte from the parametrized files, and a read-only harness against live WP has every resolver return the exact id it replaced (6/6). Both nav scripts' nowdoc → heredoc conversion was proven by rendering both versions on PHP 8.2 and diffing (byte-identical, 1557 bytes) rather than by reasoning about interpolation rules.

Post ids resolve by name, and the key is the platform's, not ours. _resolve.php keys templates on _elementor_template_type (header/footer/product/ product-archive), the homepage on page_on_front, images on filename. Config would have been the wrong fix — the ids do not exist when a config is written, because the deploy creates them. A first pass planned to join on post_title (Site Header, {brand} Product Archive); measuring the live box first killed it — Site Header is authored by no script we own, so that join key was one we'd have to keep in sync, while Elementor's type taxonomy is one it maintains itself. Every lookup exits non-zero on a miss or an ambiguity; nothing falls back to an id. See known-patterns "An id the destination assigned is an INSTALL fact".

Correction (2026-07-16) — there is no wordpress repo, and the .ppk was never committed

This ADR's §Context and §Related call the pilot's tooling "the wordpress repo", and known-issues carried "the wordpress repo has cathal.dev.ppk committed at root and live WC ck_/cs_ keys in config/api-credentials.md" as a security item to rotate before handover.

Correction: ~/wordpress is not a git repository. No .git at its root, no remote, no history — so nothing in it has ever been committed or pushed, and the .ppk and WC keys have never left local disk. The exposure is real but it is local-disk only, not a published secret.

Why it was believable, and why it still cost something: ~/wordpress/crawler is a repo with a real remote (DCathal/Replatform), so "the wordpress repo" was true of the part that mattered most to stage 1 — and a folder full of scripts, config and a committed-looking .ppk reads exactly like a repo. The inference (a secret is in git) never followed from the observation (a secret is in the folder). Rotation is still wise; the urgency was not what the wiki implied.

The half nobody had noticed is worse than the half that was wrong. Because it was not a repo, the pilot's bmparts scripts had no version control at all — one rm from gone — and crawler/lib/layout-analyser.js + lib/tag-scanner.js, the two files this ADR names as load-bearing for stage 1, were untracked and existed only on one laptop. Both are now committed and pushed (DCathal/Replatform a28ac81).

Open questions

  • Credentials handling — job spec references host aliases + secret store, never inline secrets; exact mechanism TBD (the leaked .ppk in the wordpress repo is the cautionary tale; rotate it regardless).
  • Elementor Pro licensing per target — programmatic deploys ride an activated install; per-client production hosts need their own licenses.
  • Open-model swap criteria (GLM or otherwise) — define an eval set (replay today's seven gotchas as fixtures) before routing builder traffic to it; the version pinned is whatever passes the evals at swap time, not whatever is newest.
  • Which destinations next — this ADR covers Wix/Ecwid → WooCommerce. The Astro lane (ADR-0009) is untouched; a shared job spec across lanes is desirable but not designed here.