Skip to content

Commit 7206744

Browse files
committed
1.0.0-gamma.10: capture Lovable Nitro sites and stream media (from a4c00dd)
1 parent aeb9e78 commit 7206744

11 files changed

Lines changed: 434 additions & 72 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.9",
17+
"version": "1.0.0-gamma.10",
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.9",
5+
"version": "1.0.0-gamma.10",
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.9",
3+
"version": "1.0.0-gamma.10",
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.9
1+
1.0.0-gamma.10

‎skills/html2wp/SKILL.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,10 @@ next conversion — this site or any other.
271271
python3 assets/scripts/detect-project.py <project> --out {workspace}/detect.json
272272
```
273273

274+
Use the returned `root` for **every** preparation command, including web-app
275+
prerender and static-site builds. An uploaded ZIP may contain a nested project;
276+
the upload directory is not necessarily the directory with `package.json`.
277+
274278
| kind | what it is | prepared by |
275279
|---|---|---|
276280
| `static-html` | a folder of `.html` pages | nothing (stage 0 takes a copy) |
@@ -636,7 +640,7 @@ container has no `apply_patch` command.
636640
| mode | `progress.sh mode flash` | — | — |
637641
| -4 | `whats-here.sh {workspace}` (a finished or part-finished workspace: follow its route — a Continue resumes, never restarts); `allowance.sh` (print its line verbatim); `detect-project.py <project> --out {workspace}/detect.json` | — | kind `none`; a site over the page allowance (say it, with the service's words) |
638642
| -3 | `$S/check-prereqs.sh` — in the app exactly `/opt/html2wp/skills/html2wp/assets/scripts/check-prereqs.sh` | — | a missing tool (it prints what to install) |
639-
| -1 | by kind — `static-html`: `rsync -a --exclude .git --exclude node_modules --exclude .html2wp <root>/ {input}/`, then `prepare-source-assets.py --input {input} --project <root>` — `<root>` is detect.json's `root`, never `<project>` itself (a site nested in a folder, `<project>/<name>/index.html`, is taken from that folder); `web-app`: `prerender-spa.py --project <project> --out {input} --no-verify --flash` (a group of pages — a route table's `/product/:id`, or pages sharing a path with one varying last segment like a TanStack `/blog/<slug>` — is recorded once, on its first page, and each other page shares that recording only when its controls are the same; the pages are captured in `--jobs` browsers, entrance motion is waited for at most 6 s; it says "N of M" as it goes); `static-site`: `static-site.py --project <project> --out {input}` (exit 3 → `prerender-spa.py … --no-verify --flash` instead, once); `html2wp-astro`: `detect-project.py <project> --prepare {workspace}` | — | no pages written (exit 2; 1 from static-site.py) |
643+
| -1 | by kind — `static-html`: `rsync -a --exclude .git --exclude node_modules --exclude .html2wp <root>/ {input}/`, then `prepare-source-assets.py --input {input} --project <root>` — `<root>` is detect.json's `root`, never `<project>` itself (a site nested in a folder, `<project>/<name>/index.html`, is taken from that folder); `web-app`: `prerender-spa.py --project <root> --out {input} --no-verify --flash` (a group of pages — a route table's `/product/:id`, or pages sharing a path with one varying last segment like a TanStack `/blog/<slug>` — is recorded once, on its first page, and each other page shares that recording only when its controls are the same; the pages are captured in `--jobs` browsers, entrance motion is waited for at most 6 s; it says "N of M" as it goes); `static-site`: `static-site.py --project <root> --out {input}` (exit 3 → `prerender-spa.py … --no-verify --flash` instead, once); `html2wp-astro`: `detect-project.py <project> --prepare {workspace}` | — | no pages written (exit 2; 1 from static-site.py) |
640644
| 0 | `cp -a {input} {workspace}/input-untouched`; `analyze-input.mjs {input} --out={workspace}/analysis.json`; `flash-manifest.py --analysis {workspace}/analysis.json --input {input} --workspace {workspace}`; decide (below); `check-manifest.py --manifest={workspace}/conversion-manifest.json --flash` | — | analyze exit 2 (refusal, key collision); check-manifest still red after its lever |
641645
| -1b | a shop: `capture-commerce-specimen.py --dist {input} --out {workspace}/style-specimens`; no shop: `skip` (it comes after stage 0 because stage 0 decides the shop) | a nonzero exit | — |
642646
| 0.5 | `optimize-images.py --input {input} --remote --apply --out {workspace}/optimize-images-report.json` (`skip` for `html2wp-astro`) | a nonzero exit | — |
@@ -1345,6 +1349,22 @@ React's hydration markers (`<!--$-->`, `<!-- -->`) are stripped from the
13451349
capture. Without Docker it will not edit the client's Vite config — enable the
13461350
prerender yourself or install Docker.
13471351

1352+
For the supported Lovable config wrapper **2.5.3** with an explicit
1353+
`nitro: { preset: "cloudflare-module", ... }`, the isolated capture build
1354+
sets `nitro: false` through the wrapper's supported option, retaining
1355+
TanStack SSR/server options. This prevents the deployment adapter from
1356+
replacing the native `server.js` entry required by TanStack prerender with
1357+
a Worker `index.mjs`. The source config remains unchanged. Unsupported
1358+
Lovable/Cloudflare config forms get `UNSUPPORTED_CAPTURE_ADAPTER`, rather
1359+
than blindly enabling an incompatible prerender. Do not rename Worker
1360+
entrypoints or invent missing backend credentials as a repair.
1361+
1362+
Browser readiness waits for the document and a 500 ms quiet period for
1363+
non-media requests (30 s budget). An active audio/video stream is reported
1364+
and does not block capture; pending scripts, data, images and fonts still
1365+
count. Existing image/font/motion checks remain in place. Missing backend
1366+
configuration or inherited runtime errors remain limitations in the report.
1367+
13481368
**No route table** (no React Router `<Route path>`: a Vite multi-page build, a
13491369
static export, a hydrating Astro build): the script builds first and takes the
13501370
pages the build wrote as the routes (`routesFrom: "build output"` in the

‎skills/html2wp/VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.0-gamma.9
1+
1.0.0-gamma.10
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Network-idle readiness with media streams excluded, not data or scripts."""
2+
import time
3+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
4+
5+
6+
def track_page(page):
7+
if hasattr(page, '_h2wp_network'):
8+
return
9+
state = {'pending': set(), 'media': set(), 'last': time.monotonic(), 'reported': False}
10+
page._h2wp_network = state
11+
12+
def started(request):
13+
if request.resource_type == 'media':
14+
state['media'].add(request)
15+
else:
16+
state['pending'].add(request)
17+
state['last'] = time.monotonic()
18+
19+
def finished(request):
20+
state['media'].discard(request)
21+
if request in state['pending']:
22+
state['pending'].discard(request)
23+
state['last'] = time.monotonic()
24+
25+
page.on('request', started)
26+
page.on('requestfinished', finished)
27+
page.on('requestfailed', finished)
28+
29+
30+
def track_context(context):
31+
context.on('page', track_page)
32+
for page in context.pages:
33+
track_page(page)
34+
35+
36+
def remaining(deadline):
37+
budget = int((deadline - time.monotonic()) * 1000)
38+
if budget <= 0:
39+
raise PlaywrightTimeoutError('Document and non-media requests exceeded the navigation budget')
40+
return budget
41+
42+
43+
def wait_ready(page, warn=None, timeout=30000, _deadline=None):
44+
deadline = _deadline if _deadline is not None else time.monotonic() + timeout / 1000
45+
# Late callers without a complete request history keep native semantics.
46+
if not hasattr(page, '_h2wp_network'):
47+
page.wait_for_load_state('networkidle', timeout=remaining(deadline))
48+
return
49+
page.wait_for_load_state('domcontentloaded', timeout=remaining(deadline))
50+
state = page._h2wp_network
51+
quiet_since = time.monotonic()
52+
while time.monotonic() < deadline:
53+
now = time.monotonic()
54+
if state['pending']:
55+
quiet_since = now
56+
if not state['pending'] and now - max(quiet_since, state['last']) >= 0.5:
57+
if state['media'] and not state['reported']:
58+
if warn:
59+
warn('network readiness: media stream still active; document and non-media requests settled')
60+
state['reported'] = True
61+
return
62+
page.wait_for_timeout(50)
63+
raise PlaywrightTimeoutError('Non-media requests did not settle within the navigation budget')
64+
65+
66+
def goto_ready(page, url, warn=None, timeout=30000):
67+
deadline = time.monotonic() + timeout / 1000
68+
track_page(page)
69+
response = page.goto(url, wait_until='domcontentloaded', timeout=remaining(deadline))
70+
if response is not None and response.status >= 400:
71+
raise RuntimeError(f'Source document returned HTTP {response.status}')
72+
wait_ready(page, warn=warn, _deadline=deadline)
73+
return response
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Capture-only TanStack build adaptation; call only on a disposable copy."""
2+
import json
3+
import re
4+
from pathlib import Path
5+
6+
CAPTURE_NOTE = '// html2wp capture copy only: retain native SSR; omit deployment bundling.'
7+
def has_cloudflare_nitro(text):
8+
# Treat strings/templates as single tokens; ignore commented examples.
9+
tokens = re.findall(r'''//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|[\w$]+|[^\s]''', text)
10+
tokens = [t for t in tokens if not t.startswith(('//', '/*'))]
11+
return any(tokens[i:i+5] == ['nitro', ':', '{', 'preset', ':']
12+
and tokens[i+5] in ('"cloudflare-module"', "'cloudflare-module'")
13+
for i in range(max(0, len(tokens)-5)))
14+
15+
16+
def lovable_version(work):
17+
try:
18+
package = json.loads((work / "package.json").read_text())
19+
deps = {**(package.get("dependencies") or {}), **(package.get("devDependencies") or {})}
20+
return deps.get("@lovable.dev/vite-tanstack-config")
21+
except (OSError, ValueError, TypeError):
22+
return None
23+
24+
25+
class UnsupportedCaptureConfig(ValueError):
26+
pass
27+
28+
29+
def already_adapted(text):
30+
return bool(re.match(
31+
r'\Aimport \{ defineConfig as (__h2wpCaptureDefineConfig_*) \} from "@lovable\.dev/vite-tanstack-config";\n'
32+
+ re.escape(CAPTURE_NOTE) + r'\nconst ([A-Za-z_$][\w$]*) = \(options = \{\}\) => \1\(\{ \.\.\.options, nitro: false \}\);\n'
33+
r'\s*export\s+default\s+\2\s*\(\s*\{', text))
34+
35+
36+
def lovable_capture_config(text, work):
37+
"""Use Lovable v2's supported no-deploy mode, preserving its SSR options.
38+
39+
Only the literal, single named-import/object-call form is rewritten.
40+
Unknown wrappers and function configs retain their existing behavior.
41+
This is not a deployment config or a rename of the Worker entrypoint.
42+
"""
43+
version = lovable_version(work)
44+
if version != '2.5.3':
45+
return text, None
46+
# Only the explicit deployment preset known to replace the SSR entry.
47+
if not has_cloudflare_nitro(text):
48+
return text, None
49+
match = re.match(
50+
r"\A\s*import\s*\{\s*defineConfig(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*\}"
51+
r"\s*from\s*(['\"])@lovable\.dev/vite-tanstack-config\2\s*;?", text)
52+
if not match:
53+
return text, None
54+
local = match.group(1) or "defineConfig"
55+
if not re.match(r"\s*export\s+default\s+" + re.escape(local) + r"\s*\(\s*\{", text[match.end():]):
56+
return text, None
57+
alias = "__h2wpCaptureDefineConfig"
58+
while alias in text:
59+
alias += "_"
60+
replacement = (
61+
f'import {{ defineConfig as {alias} }} from "@lovable.dev/vite-tanstack-config";\n'
62+
f'{CAPTURE_NOTE}\n'
63+
f'const {local} = (options = {{}}) => {alias}({{ ...options, nitro: false }});\n'
64+
)
65+
return replacement + text[match.end():], "Lovable v2 capture build: Nitro deployment adapter disabled"
66+
67+
68+
TANSTACK_PRERENDER = "prerender: { enabled: true, crawlLinks: true }"
69+
70+
71+
def enable_tanstack_prerender(work):
72+
"""Turn on TanStack Start's static prerender in the build copy's Vite
73+
config. Returns what was done, or None when no config could be patched
74+
(the build then fails loudly on "no index.html", as before)."""
75+
for name in ("vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"):
76+
cfg = Path(work) / name
77+
if cfg.is_file() and not cfg.is_symlink():
78+
break
79+
else:
80+
return None
81+
text = cfg.read_text()
82+
text, capture_note = lovable_capture_config(text, Path(work))
83+
if lovable_version(Path(work)) and has_cloudflare_nitro(text) and not capture_note and not already_adapted(text):
84+
raise UnsupportedCaptureConfig(
85+
'Nitro cloudflare-module needs a compatible capture adapter; '
86+
'supported: @lovable.dev/vite-tanstack-config 2.5.3 with a direct '
87+
'named defineConfig import and literal export options. '
88+
'Configuration was left unchanged; do not force prerender on.')
89+
if re.search(r"prerender\s*:\s*\{[^}]*enabled\s*:\s*false", text):
90+
new = re.sub(r"(prerender\s*:\s*\{[^}]*enabled\s*:\s*)false", r"\1true", text, count=1)
91+
how = "prerender.enabled flipped to true"
92+
elif re.search(r"\bprerender\s*:", text):
93+
new = text
94+
how = "prerender already configured — left as authored"
95+
elif re.search(r"tanstackStart\s*:\s*\{", text):
96+
new = re.sub(r"(tanstackStart\s*:\s*\{)", r"\1 " + TANSTACK_PRERENDER + ",", text, count=1)
97+
how = "prerender added to tanstackStart: {…}"
98+
elif re.search(r"tanstackStart\(\s*\{", text):
99+
new = re.sub(r"(tanstackStart\(\s*\{)", r"\1 " + TANSTACK_PRERENDER + ",", text, count=1)
100+
how = "prerender added to tanstackStart({…})"
101+
elif re.search(r"tanstackStart\(\s*\)", text):
102+
new = re.sub(r"tanstackStart\(\s*\)", "tanstackStart({ " + TANSTACK_PRERENDER + " })", text, count=1)
103+
how = "prerender added to tanstackStart()"
104+
elif "@lovable.dev/vite-tanstack-config" in text and re.search(r"defineConfig\(\s*\{", text):
105+
new = re.sub(r"(defineConfig\(\s*\{)", r"\1 tanstackStart: { " + TANSTACK_PRERENDER + " },", text, count=1)
106+
how = "tanstackStart.prerender added to the Lovable config"
107+
elif "@lovable.dev/vite-tanstack-config" in text and re.search(r"defineConfig\(\s*\)", text):
108+
new = re.sub(r"defineConfig\(\s*\)", "defineConfig({ tanstackStart: { " + TANSTACK_PRERENDER + " } })", text, count=1)
109+
how = "tanstackStart.prerender added to the Lovable config"
110+
else:
111+
return None
112+
cfg.write_text(new)
113+
return f"{cfg.name}: {how}" + (f"; {capture_note}" if capture_note else "")

0 commit comments

Comments
 (0)