Skip to content

Commit 1d1d630

Browse files
iOSDevSKclaude
andcommitted
gamma.2: changes after delivery in the live theme, never a new build; Get ZIP (from dc14cad)
Not released: local branch; the release is the lead's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xf7wTz9XZFZ1GCWzDzHaWW
1 parent 77f0093 commit 1d1d630

13 files changed

Lines changed: 656 additions & 10 deletions

File tree

‎.claude-plugin/marketplace.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"source": "./",
1515
"displayName": "html2wp — HTML to WordPress",
1616
"description": "BETA. Convert a static HTML site into a standalone WordPress block theme, verified against the original at three viewport widths — and reviewed by you before handover. The conversion runs through a verified Astro 5 build, so the Astro project is a real output too — compared pixel by pixel against the source before anything WordPress-shaped is generated.",
17-
"version": "1.0.0-gamma.1",
17+
"version": "1.0.0-gamma.2",
1818
"author": {
1919
"name": "BELNEM s.r.o.",
2020
"email": "hello@html2wp.dev",

‎.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "html2wp",
33
"displayName": "html2wp — HTML to WordPress",
44
"description": "BETA. Convert a static HTML site — or anything that builds to one (Lovable, Bolt, v0, Next export) — into a standalone WordPress block theme: verified pixel by pixel, pages, blog, forms, menus, SEO and redirects, with no plugin required and verification gates at every stage. Two modes: Full (every gate repaired until green, then your own page-by-page review) and Flash (every stage once, no repair loop, the gates reported). The gates catch most fidelity drift, not all of it.",
5-
"version": "1.0.0-gamma.1",
5+
"version": "1.0.0-gamma.2",
66
"author": {
77
"name": "BELNEM s.r.o.",
88
"email": "hello@html2wp.dev",

‎.codex-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "html2wp",
3-
"version": "1.0.0-gamma.1",
3+
"version": "1.0.0-gamma.2",
44
"description": "Convert static HTML or buildable web projects into verified, editable WordPress block themes.",
55
"author": {
66
"name": "BELNEM s.r.o.",

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.0-gamma.1
1+
1.0.0-gamma.2

‎skills/html2wp/SKILL.md‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,9 @@ activates without an admin error.
357357
| **Full** | this file end to end: every gate, repaired until it is green, and the owner's page-by-page review | the hand-over run; the default in a CLI |
358358
| **Flash** | every stage ONCE, no repair loop. At stage 0 you identify the menus, the blog (posts and listing), the shop and every form, so each is wired and connectable in Visual Edit Lite; the gates run once and are REPORTED, not repaired | a converted, installed, honestly reported theme in about an hour; the desktop app's default |
359359
| **Astro** (`H2WP_MODE=astro`, `H2WP_TARGET=astro`) | the Astro 5 project only: stages -1 to 1 and gates A/A2, each once — no service, no WordPress, no theme | the owner who wants the static site or its source, not WordPress |
360+
| **Change** (`H2WP_MODE=change`) | after delivery: the owner's change made in the LIVE theme and applied to the running preview in seconds — never a new build (section "Changes after delivery") | every request once the project is delivered (`{workspace}/result.json` status `delivered`) |
360361

361-
- **`H2WP_MODE` set** (`flash`, `full` or `astro`): use it and do not ask. It wins over
362+
- **`H2WP_MODE` set** (`flash`, `full`, `astro` or `change`): use it and do not ask. It wins over
362363
the wording of the request — a prompt cannot turn a Flash run into a repair
363364
loop. Any other value: stop and say so.
364365
- **Not set**: Flash only when the owner asked for it ("Flash", "one pass",
@@ -562,6 +563,54 @@ until connected in Visual Edit Lite", and the collections. Then the owner's
562563
next steps: open the preview, connect each form, choose Full to have the red
563564
rows repaired. No password, token or private path.
564565

566+
## Changes after delivery — in the live theme, never a new build
567+
568+
**Once the project is delivered** (`{workspace}/result.json` says `status:
569+
"delivered"`), every change the owner asks for — "make that heading italic",
570+
"swap the footer text", "tighten the hero's spacing" — is made in the
571+
installed theme and applied to the running preview. It is never a new build:
572+
no prerender, no Astro build, no service conversion, no stage, no
573+
`progress.sh mode`. `progress.sh` refuses a stage start and a `mode` without
574+
`--new` on a delivered project (exit 3) — that refusal is the rule. A rebuild
575+
from the source is the owner's **Rebuild** button, never a chat change.
576+
577+
1. **Edit only the theme's own files**, under `{workspace}/theme/<slug>/`:
578+
`templates/*.html` and `parts/*.html` (the layout, the header and footer),
579+
the site's CSS, scripts and images in its assets, and
580+
`clara-content/sources/<key>.html` (a page's content as the importer stores
581+
it). Never `source/`, `static-src/`, `astro-project/` or the manifest —
582+
those are the build's, and the next Rebuild regenerates the theme from
583+
them. Keep the change to what was asked.
584+
2. **Apply it:**
585+
586+
```
587+
python3 assets/scripts/apply-change.py {workspace} --what "<the owner's request, one line>" [--page <key or /route/>]
588+
```
589+
590+
It packages the edited theme (make-zip's lint and refusals), replaces it in
591+
the running preview through WordPress's own upload — the importer refreshes
592+
every page whose stored source is still the bundle's — and screenshots the
593+
touched pages at 1440 and 390 into `{workspace}/changes/`. Seconds, not
594+
minutes. It logs the change in `{workspace}/changes.json` and sets
595+
`changedSinceZip`. Exit 3 = no theme file changed (you edited something
596+
else); 1 = the package or the preview refused (its reason on stderr).
597+
3. **Look before you answer:** open the screenshots it names. The change is
598+
there, and nothing else moved. If not, correct the theme file and apply
599+
again — at most twice for one request; then tell the owner what you see.
600+
4. **Answer the owner:** what changed, on which page, and that the preview
601+
shows it. The ZIP is theirs to take ("Get ZIP" runs
602+
`assets/scripts/package-theme.py`, no model turn): the live theme as the
603+
next revision.
604+
605+
**A page the owner edited in the preview** keeps the owner's text — the
606+
importer never overwrites an owner's edit — so a change to that page's source
607+
does not reach it. Say so; the owner makes it in Visual Edit Lite.
608+
609+
**A request the live theme cannot take** — a new page or route, behaviour that
610+
lives in the source app's code, content only the source's data has — needs a
611+
Rebuild from the source. Say exactly that, and that a Rebuild regenerates the
612+
theme and drops the changes made after delivery. Do not rebuild yourself.
613+
565614
## Stage -3 — can this machine run it at all?
566615

567616
**Run this before anything else, every time. Not once per machine — every
@@ -2784,6 +2833,10 @@ hunting for a deleted `static-src`.
27842833

27852834
## Post-handover repairs — run only what the fix touches
27862835

2836+
*A change the owner asks for after delivery is "Changes after delivery"
2837+
(above), in the live theme. What follows is for a repair only a rebuild can
2838+
make, and only when the owner asked for the Rebuild.*
2839+
27872840
**The theme is yours to change. The editor plugin is not. Ever.**
27882841

27892842
Visual Edit — Lite or Pro — is a RELEASED PRODUCT with its own repository, its
@@ -3315,6 +3368,14 @@ assets/scripts/rebuild-theme.sh post-handover repairs (manifest → service
33153368
On html2wp/2 it stops after the rebuild
33163369
assets/scripts/send-verdicts.sh stage 6.5 (the gates' verdicts → the
33173370
service; both targets)
3371+
assets/scripts/apply-change.py after delivery: the edited live theme into
3372+
the running preview (make-zip, WordPress's
3373+
own replace, the importer's refresh),
3374+
screenshots, changes.json — no stage runs
3375+
assets/scripts/package-theme.py after delivery, the owner's "Get ZIP": the
3376+
live theme as the next revision in
3377+
result.json, or the last ZIP when nothing
3378+
changed
33183379
assets/scripts/write-result.py the end of every run: result.json (the
33193380
verdict a UI reads), the ZIPs, the report
33203381
and its PDF into H2WP_OUTPUT_DIR

‎skills/html2wp/VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.0-gamma.1
1+
1.0.0-gamma.2
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026 BELNEM s.r.o. html2wp Source-Available Licence — see LICENSE.
3+
"""A change after delivery, applied to the running preview — in seconds.
4+
5+
apply-change.py {workspace} --what "<the owner's request, one line>"
6+
[--page <key or /route/>]... [--skip-install]
7+
8+
After delivery the owner asks for changes in the chat, and a change is made in
9+
the LIVE theme, never as a new build (SKILL.md, "Changes after delivery"). The
10+
model edits only the installed theme's files — {workspace}/theme/<slug>/:
11+
templates, parts, the site's CSS and assets, clara-content/sources — and then
12+
runs this, which:
13+
14+
1. packages the edited theme with make-zip.sh (its lint and refusals), and
15+
names the files that changed since the last change reached the preview;
16+
2. installs that package into the running preview through WordPress's own
17+
upload (install-theme.py): the theme files update, and the importer
18+
re-imports every page whose stored source is still the bundle's own — an
19+
owner's edit in the preview is never overwritten;
20+
3. screenshots the touched pages (the --page ones, else every page whose
21+
source changed, else the front page) at 1440 and 390, for the model to
22+
look at before it answers;
23+
4. logs the change in {workspace}/changes.json (what, files, pages,
24+
screenshots, when) and sets changedSinceZip, which "Get ZIP"
25+
(package-theme.py) clears.
26+
27+
No stage runs: no prerender, no build, no service conversion, no
28+
progress.json change. A preview that is down is brought back with
29+
`test-env.sh up <slug>`. --skip-install records and checks the package
30+
without touching a preview.
31+
32+
Exit 0 = applied; 1 = failed (the package or the install refused; the reason
33+
on stderr and in the log); 2 = not a delivered project, or usage; 3 = no
34+
theme file changed since the last change — nothing to apply.
35+
"""
36+
import argparse
37+
import json
38+
import subprocess
39+
import sys
40+
import time
41+
import urllib.error
42+
import urllib.request
43+
from pathlib import Path
44+
45+
HERE = Path(__file__).resolve().parent
46+
sys.path.insert(0, str(HERE / "lib"))
47+
import theme_state as ts # noqa: E402
48+
49+
WIDTHS = (1440, 390)
50+
51+
52+
def answers(url):
53+
try:
54+
with urllib.request.urlopen(urllib.request.Request(url, method="HEAD"), timeout=10) as r:
55+
return r.status < 500
56+
except urllib.error.HTTPError as e:
57+
return e.code < 500
58+
except OSError:
59+
return False
60+
61+
62+
def preview(ws, slug):
63+
state_path = ws / f".test-env-{slug}.json"
64+
if not state_path.is_file():
65+
raise RuntimeError("no preview WordPress for this project: there is nothing to apply the change to")
66+
state = json.loads(state_path.read_text())
67+
if not answers(state["url"] + "/"):
68+
up = subprocess.run(["bash", str(HERE / "test-env.sh"), "up", slug], cwd=ws, capture_output=True,
69+
text=True, timeout=900)
70+
state = json.loads(state_path.read_text())
71+
if up.returncode != 0 or not answers(state["url"] + "/"):
72+
raise RuntimeError("the preview WordPress did not come back up")
73+
return state_path, state["url"].rstrip("/")
74+
75+
76+
def touched_pages(changed, asked):
77+
"""The pages to look at: those asked for; else the pages whose stored
78+
source changed; else (templates, parts, CSS) the front page."""
79+
if asked:
80+
return asked
81+
keys = [Path(p).stem for p in changed if p.startswith("clara-content/sources/") and p.endswith(".html")]
82+
return keys or ["front-page"]
83+
84+
85+
def route_of(page):
86+
if page.startswith("/"):
87+
return page
88+
return "/" if page in ("front-page", "index") else f"/{page}/"
89+
90+
91+
def screenshots(url, pages, folder, stem):
92+
from playwright.sync_api import sync_playwright
93+
shots = []
94+
folder.mkdir(parents=True, exist_ok=True)
95+
with sync_playwright() as p:
96+
browser = p.chromium.launch()
97+
for page_name in pages:
98+
for width in WIDTHS:
99+
page = browser.new_page(viewport={"width": width, "height": 900})
100+
page.goto(url + route_of(page_name), wait_until="networkidle", timeout=60000)
101+
name = f"{stem}-{page_name.strip('/').replace('/', '-') or 'front-page'}-{width}.png"
102+
page.screenshot(path=str(folder / name), full_page=True)
103+
shots.append(f"changes/{name}")
104+
page.close()
105+
browser.close()
106+
return shots
107+
108+
109+
def main(argv=None):
110+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
111+
ap.add_argument("workspace")
112+
ap.add_argument("--what", required=True, help="the owner's request, one line, for the change log")
113+
ap.add_argument("--page", action="append", default=[], help="a page to look at: its key or its /route/")
114+
ap.add_argument("--skip-install", action="store_true", help="package and log only; no preview")
115+
args = ap.parse_args(argv)
116+
ws = Path(args.workspace).resolve()
117+
result = ts.delivered(ws)
118+
manifest = ts.read(ws / "conversion-manifest.json")
119+
if result is None or not isinstance(manifest, dict):
120+
print("apply-change: not a delivered project (no result.json with status delivered) — a change after "
121+
"delivery needs the delivered theme", file=sys.stderr)
122+
return 2
123+
theme = ts.theme_dir(ws, manifest)
124+
slug, version = manifest["site"]["slug"], (manifest.get("site") or {}).get("version") or "1.0.0"
125+
output = ts.output_dir(ws)
126+
current = ts.tree(theme)
127+
applied = ts.read(ws / ".theme-applied.json")
128+
zipped = ts.last_zip(ws, result, output)
129+
base = applied if isinstance(applied, dict) else (ts.zip_tree(zipped) if zipped else {})
130+
changed = ts.differ(base, current)
131+
if not changed:
132+
print(f"apply-change: no file of {theme} changed since the last change reached the preview — "
133+
"nothing to apply (edit the theme's own files, never the source)", file=sys.stderr)
134+
return 3
135+
136+
log = ts.load_changes(ws)
137+
entry = {"id": len(log["changes"]) + 1, "at": ts.now(), "what": args.what.strip()[:300], "files": changed,
138+
"pages": [], "screenshots": [], "applied": False}
139+
started = time.time()
140+
try:
141+
package = ws / ".change-apply" / f"{slug}-{version}.zip"
142+
ok, said = ts.make_zip(theme, package, ws / "conversion-manifest.json")
143+
if not ok:
144+
raise RuntimeError("the edited theme does not package: " + said.splitlines()[-1] if said else "make-zip failed")
145+
if args.skip_install:
146+
entry["install"] = "skipped"
147+
else:
148+
state_path, url = preview(ws, slug)
149+
run = subprocess.run([sys.executable, str(HERE / "install-theme.py"), "--env", str(state_path),
150+
"--theme", str(package), f"--manifest={ws / 'conversion-manifest.json'}",
151+
"--out", str(ws / ".change-apply" / "install"), "--update"],
152+
capture_output=True, text=True, timeout=900)
153+
if run.returncode != 0:
154+
tail = (run.stdout + run.stderr).strip().splitlines()[-2:]
155+
raise RuntimeError("the preview did not take the change: " + " | ".join(tail))
156+
entry["install"] = "installed"
157+
entry["pages"] = touched_pages(changed, args.page)
158+
entry["screenshots"] = screenshots(url, entry["pages"], ws / "changes", f"{entry['id']:03d}")
159+
entry["applied"] = True
160+
ts.write_json(ws / ".theme-applied.json", current)
161+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as error:
162+
entry["error"] = str(error)[:600]
163+
print(f"apply-change: {entry['error']}", file=sys.stderr)
164+
entry["seconds"] = round(time.time() - started, 1)
165+
log["changes"].append(entry)
166+
last = ts.zip_tree(zipped) if zipped else {}
167+
log["changedSinceZip"] = bool(ts.differ(last, current))
168+
ts.write_json(ws / "changes.json", log)
169+
print(json.dumps({k: entry[k] for k in ("id", "applied", "files", "pages", "screenshots", "seconds") if k in entry}))
170+
return 0 if entry["applied"] else 1
171+
172+
173+
if __name__ == "__main__":
174+
sys.exit(main())

0 commit comments

Comments
 (0)