|
| 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