Skip to content

Commit d71cfd7

Browse files
iOSDevSKclaude
andcommitted
Release 1.0.0-beta.28 (from 66425fe)
Synced from iOSDevSK/html2wp-sub-protected feature/dual-target @ 66425fe3f860c0a0b3f94b55c2f8ef03761ea3d2 by tools/sync-cc.sh / tools/sync-codex.sh, verified: scripts parse, manifests agree, moat and closure clean, no debris, no secrets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xf7wTz9XZFZ1GCWzDzHaWW
1 parent 35a778b commit d71cfd7

13 files changed

Lines changed: 867 additions & 24 deletions

‎skills/html2wp/SKILL.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,6 +1194,10 @@ not only the first.**
11941194

11951195
### When gate A is red — find the step, don't guess
11961196

1197+
**Any red gate or blocking finding, on either target:** repair it by
1198+
[references/repair.md](references/repair.md) (how to localize it, the allowed
1199+
levers, when to report).
1200+
11971201
```
11981202
assets/scripts/gate-a-bisect.sh {workspace} [--original-remote={workspace}/optimize-images-report.json] [--pages=a.html,b.html]
11991203
```

‎skills/html2wp/assets/scripts/compare-pages.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,16 @@ def settle(page):
102102
await Promise.all(pending.map((i) => new Promise((r) => { i.onload = i.onerror = r; })));
103103
}""")
104104
page.evaluate("""async () => {
105+
// A page's `scroll-behavior: smooth` makes each scrollTo an animation
106+
// that the next one retargets: the walk crept ~500 px down a 10,000 px
107+
// page, and what it was meant to bring in (lazy images, reveals) came
108+
// in or not by chance. Instant for the walk, then the page's own again.
109+
const de = document.documentElement, was = de.style.scrollBehavior;
110+
de.style.scrollBehavior = 'auto';
105111
const h = document.body.scrollHeight;
106112
for (let y = 0; y < h; y += 700) { window.scrollTo(0, y); await new Promise(r => setTimeout(r, 60)); }
107113
window.scrollTo(0, 0);
114+
de.style.scrollBehavior = was;
108115
}""")
109116
# `complete` only means the response finished; a large PNG may still be
110117
# waiting for its decode before Chromium can paint it. Full-page captures

‎skills/html2wp/assets/scripts/gutenberg-editor-visual.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,25 @@
1818
ROOT='.is-root-container'
1919
# children: measure the union of the element's children (a template part's
2020
# blocks inside the full-width editor canvas root).
21+
# A box-less region (display:contents post content) is the union of its
22+
# descendants' boxes; a position:fixed one (a mobile drawer kept in the post
23+
# body) sits in the viewport, not in the region, and moved the union with
24+
# every scroll ("Canvas height changed while capturing"), so it is left out
25+
# unless the region is nothing but fixed boxes (a fixed header part).
26+
# The editor's own drop zone, an absolute overlay over the whole canvas, is
27+
# always left out.
2128
GEOMETRY="""(e,children)=>{
22-
const boxes=n=>{const r=n.getBoundingClientRect();if(r.width&&r.height)return [r];return [...n.children].flatMap(boxes)};
23-
const rs=children?[...e.children].flatMap(boxes):boxes(e);if(!rs.length)return {x:0,y:0,width:0,height:0,scroll:scrollY,viewport:innerHeight};
29+
const collect=fixed=>{const boxes=n=>{if(n!==e&&(n.matches('.components-drop-zone')||(!fixed&&getComputedStyle(n).position==='fixed')))return [];const r=n.getBoundingClientRect();if(r.width&&r.height)return [r];return [...n.children].flatMap(boxes)};return children?[...e.children].flatMap(boxes):boxes(e)};
30+
let rs=collect(false);if(!rs.length)rs=collect(true);if(!rs.length)return {x:0,y:0,width:0,height:0,scroll:scrollY,viewport:innerHeight};
2431
const x=Math.min(...rs.map(r=>r.x)),y=Math.min(...rs.map(r=>r.y));
2532
return {x,y,width:Math.max(...rs.map(r=>r.right))-x,height:Math.max(...rs.map(r=>r.bottom))-y,scroll:scrollY,viewport:innerHeight};
2633
}"""
2734

2835

2936
def ready(frame):
37+
# Instant scrolling: a source `scroll-behavior:smooth` makes scrollTo
38+
# animate, and a tile captured mid-scroll repeats or skips rows.
39+
frame.add_style_tag(content='html,body{scroll-behavior:auto!important}')
3040
# Scroll through first: recorded reveals (data-spa-reveal) show as they
3141
# enter the viewport, as on the source.
3242
frame.evaluate('''async () => {
@@ -188,6 +198,17 @@ def case(args,item,width):
188198
ref=front.locator(item['selector']).first.evaluate(GEOMETRY,False)
189199
editor_selector=item.get('editorSelector',ROOT)
190200
editor_geometry=frame.locator(editor_selector).first.evaluate(GEOMETRY,bool(item.get('editorChildren')))
201+
if item['kind']=='template-parts' and not (ref['width']>0 and ref['height']>0):
202+
# The part renders nothing at this width on the site either (a
203+
# bar shown only on phones): an empty canvas is then parity, and
204+
# anything the editor paints is not.
205+
row.update(empty=True,actualWidth=frame.evaluate('innerWidth'),editorBox={k:editor_geometry[k] for k in ('width','height')},referenceBox={k:ref[k] for k in ('width','height')})
206+
row['passed']=not (editor_geometry['width']>0 and editor_geometry['height']>0)
207+
if not row['passed']: row['error']='Part renders nothing on the site at this width but paints in the editor'
208+
else: row['diff']=0.0
209+
browser.close()
210+
print(f'editor visual {row["kind"]} {row["id"]} {width}: empty on both sides',flush=True)
211+
return row
191212
handle=frame.frame_element();outer=handle.bounding_box()
192213
alignment={axis:(ref[axis]%1)-((editor_geometry[axis]+outer[axis])%1) for axis in ('x','y')}
193214
frame.locator(ROOT).evaluate('''(e,a)=>{

‎skills/html2wp/assets/scripts/gutenberg-package.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ def valid_editor_visual(row, region):
7272
and bool(row.get('frontendScreenshot')) and bool(row.get('editorScreenshot')))
7373

7474

75+
def valid_empty_part(row, region):
76+
"""A part that renders nothing at this width on the site (a bar shown
77+
only on phones) and nothing in its editor canvas either."""
78+
boxes = [row.get('editorBox'), row.get('referenceBox')]
79+
return (row.get('kind') == 'template-parts' and row.get('empty') is True and valid_diff(row)
80+
and row.get('region') == region and row.get('actualWidth') == row.get('width')
81+
and all(isinstance(box, dict) and not (box.get('width', 0) > 0 and box.get('height', 0) > 0) for box in boxes))
82+
83+
7584
def validate_new_post(root, report):
7685
proof = report.get('newPost')
7786
if not isinstance(proof, dict):
@@ -302,7 +311,7 @@ def validate_evidence(root, report, theme_report=None):
302311
if needs_visual:
303312
for width in (1440, 820, 390):
304313
if not any(row.get('kind') == kind and row.get('id') == expected and row.get('width') == width
305-
and valid_editor_visual(row, region) for row in report['editorVisual']):
314+
and (valid_editor_visual(row, region) or valid_empty_part(row, region)) for row in report['editorVisual']):
306315
raise ValueError(f'Missing matching editor {region} visual for {expected} at {width}px')
307316
for name in ('style.css', 'theme.json', 'templates/index.html', 'functions.php'):
308317
if not (root / name).is_file():

‎skills/html2wp/assets/scripts/gutenberg-plan.py‎

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import io
1212
import json
1313
from pathlib import Path, PurePosixPath
14+
import posixpath
1415
import re
1516
import shutil
1617
import sys
@@ -348,7 +349,10 @@ def fail(code, detail):
348349
entry['mapper'].finding(code, detail)
349350
return None
350351
owned = [[i for i, (node, part, place) in enumerate(e['items']) if part is None and place in ('head', 'main', 'tail')] for e in entries]
351-
if not owned[0] or any(o != owned[0] for o in owned) or owned[0] != list(range(owned[0][0], owned[0][-1] + 1)):
352+
# Contiguous up to the shared parts between them (a drawer or overlay
353+
# before the header): the template renders each part in its place.
354+
between = lambda e, o: [i for i in range(o[0], o[-1] + 1) if e['items'][i][1] is None] if o else []
355+
if not owned[0] or any(o != owned[0] for o in owned) or owned[0] != between(entries[0], owned[0]):
352356
return fail('article-template-variant', 'Post pages do not share one contiguous section layout; no article template was derived')
353357
index = owned[0]
354358
if any(shallow(e['items'][i][0]) != shallow(entries[0]['items'][i][0]) for e in entries for i in index):
@@ -358,6 +362,8 @@ def fail(code, detail):
358362
if not differing:
359363
return fail('article-template-variant', 'Post pages have identical sections; no article body found')
360364
body_index = max(differing, key=lambda i: sum(text_size(e['items'][i][0]) for e in entries))
365+
if any(e['items'][i][1] for e in entries for i in range(body_index, index[-1] + 1)):
366+
return fail('article-template-variant', 'A shared part sits between the article body and the sections after it; no article template was derived')
361367

362368
def path(node):
363369
steps = []
@@ -392,6 +398,15 @@ def head_end(body):
392398
if body_from and any(head_end(b) != body_from or any(shallow(x) != shallow(y) for x, y in zip(
393399
[c for c in b.children[:body_from] if isinstance(c, Node)], [c for c in bodies[0].children[:body_from] if isinstance(c, Node)])) for b in bodies):
394400
body_from = 0
401+
# The article's own picture right after the headline (each post its own
402+
# file) belongs to the head too: the template binds it to the post's
403+
# featured image, which the listing's cards then show.
404+
def next_node(body, start):
405+
return next(((j, c) for j, c in enumerate(body.children) if j >= start and isinstance(c, Node)), (None, None))
406+
if body_from:
407+
heroes = [next_node(b, body_from) for b in bodies]
408+
if all(n is not None and n.tag == 'img' and j == heroes[0][0] for j, n in heroes) and len({n.attrs.get('src') for _, n in heroes}) == len(heroes):
409+
body_from = heroes[0][0] + 1
395410
overrides = {id(bodies[0]): {'body': True, **({'from': body_from} if body_from else {})}}
396411
body_ids = {id(b) for b in bodies}
397412
post_metas = [metas[e['key']] for e in entries]
@@ -945,13 +960,29 @@ def card_target(self, node):
945960
targets.add(target[5:].split('#')[0])
946961
return targets.pop() if len(targets) == 1 else None
947962

963+
@staticmethod
964+
def rule(node):
965+
"""An empty decorative box (a divider closing a list): no text, media or links."""
966+
return isinstance(node, Node) and not plain(node).strip() and not any(
967+
n.tag in ('img', 'svg', 'video', 'iframe', 'picture', 'canvas', 'a', 'button', 'input') for n in walk(node))
968+
969+
def card_children(self, node):
970+
"""node's element children less the dividers that open or close the list."""
971+
kids = [c for c in node.children if isinstance(c, Node)]
972+
start, end = 0, len(kids)
973+
while start < end and self.rule(kids[start]):
974+
start += 1
975+
while end > start and self.rule(kids[end - 1]):
976+
end -= 1
977+
return kids[start:end]
978+
948979
def card_list(self, node):
949980
"""Post keys when node's children are >= 2 identical cards linking to distinct posts."""
950981
if not self.posts or not isinstance(node, Node) or node.tag not in ELEMENT_TAGS or node.tag in ('a', 'svg'):
951982
return None
952983
if any(isinstance(c, str) and c.strip() for c in node.children):
953984
return None
954-
cards = [c for c in node.children if isinstance(c, Node)]
985+
cards = self.card_children(node)
955986
if len(cards) < 2 or len({shape(c) for c in cards}) != 1:
956987
return None
957988
targets = [self.card_target(c) for c in cards]
@@ -974,7 +1005,10 @@ def query_block(self, node, targets, cards=None):
9741005
"""core/query > core/post-template > first card with element binds (spec 2 D6).
9751006
`cards` defaults to node's children; a lone lead card passes itself."""
9761007
lone = cards is not None
977-
cards = cards or [c for c in node.children if isinstance(c, Node)]
1008+
cards = cards or self.card_children(node)
1009+
# Dividers that open or close the list stay the container's own.
1010+
kids = [c for c in node.children if isinstance(c, Node)]
1011+
rules = ([], []) if lone else (kids[:kids.index(cards[0])], kids[kids.index(cards[-1]) + 1:])
9781012
overrides, found = {}, [{} for _ in targets]
9791013
diff_walk(cards, [self.posts[key] for key in targets], overrides, found, self, native_title=False)
9801014
# The summary a card prints becomes that post's excerpt (unless the
@@ -1016,7 +1050,7 @@ def query_block(self, node, targets, cards=None):
10161050
# The container keeps its element, and the cards stay its own children,
10171051
# when the design styles a card by its place among them
10181052
# (`.row:first-child`, `.row:nth-child(4)`, `.row + .row`).
1019-
wrap = bool(extra or attrs.get('anchor')) or (not lone and self.structural(cards[0]))
1053+
wrap = bool(extra or attrs.get('anchor') or rules[0] or rules[1]) or (not lone and self.structural(cards[0]))
10201054
template = {'name': 'core/post-template', 'attributes': {**({'className': attrs['className']} if attrs.get('className') and not wrap else {}), 'layout': {'type': 'default'}}, 'innerBlocks': [card] if card else []}
10211055
# Cards listing the posts after the newest ones (a grid below a lead
10221056
# article) skip those, so no post is shown twice.
@@ -1031,7 +1065,8 @@ def query_block(self, node, targets, cards=None):
10311065
# the cards stay the container's own grid or flex items.
10321066
block['attributes']['className'] = 'h2wp-query-contents'
10331067
template['attributes']['className'] = 'h2wp-query-contents'
1034-
return {'name': 'h2wp/element', 'attributes': {**attrs, 'tagName': node.tag, **({'htmlAttributes': extra} if extra else {})}, 'innerBlocks': [block]}
1068+
around = [[b for b in (self.block(r) for r in side) if b] for side in rules]
1069+
return {'name': 'h2wp/element', 'attributes': {**attrs, 'tagName': node.tag, **({'htmlAttributes': extra} if extra else {})}, 'innerBlocks': around[0] + [block] + around[1]}
10351070
return block
10361071

10371072
def container_block(self, node, inner):
@@ -2829,7 +2864,7 @@ def label(block, child, mapper):
28292864
# Nothing mappable (e.g. only unmapped elements): keep coverage on a placeholder.
28302865
mapper.finding('article-body-empty', 'Article body has no mappable content; an empty group carries its source ids')
28312866
kids = [{'name': 'core/group', 'attributes': {'layout': {'type': 'default'}}, 'innerBlocks': []}]
2832-
kids[0]['sourceIds'] = [sections[i] for i in owned if i < index] + [section]
2867+
kids[0]['sourceIds'] = [sections[i] for i in owned if i < index and not any(items[j][1] for j in range(i, index))] + [section]
28332868
kids[-1]['sourceIds'] = kids[-1].get('sourceIds', []) + [sections[i] for i in owned if i > index]
28342869
blocks.extend(kids)
28352870
continue
@@ -2839,10 +2874,16 @@ def label(block, child, mapper):
28392874
if block and part:
28402875
page_parts[part] = {'section': section, 'blocks': [block], 'source': child, 'active': has_active(child)}
28412876
block = {'name': 'core/template-part', 'attributes': {'slug': part}}
2877+
# Template-owned sections just before this part (an overlay
2878+
# ahead of the header) are covered here, in source order: the
2879+
# article body covers only those it is not cut off from by a part.
2880+
body = article['body_index'] if owned else -1
2881+
earlier = [sections[i] for i in owned if i < index and i < body and not any(items[j][1] for j in range(i, index))
2882+
and any(items[j][1] for j in range(i, body))]
28422883
elif block:
28432884
label(block, child, mapper)
28442885
if block:
2845-
block['sourceIds'] = [section]
2886+
block['sourceIds'] = (earlier if block['name'] == 'core/template-part' and owned else []) + [section]
28462887
blocks.append(block)
28472888
mapped.append((block, place))
28482889
frames.append((key, entry['frame'], page_parts))
@@ -3331,11 +3372,14 @@ def phrasing(node):
33313372

33323373

33333374
def fingerprint(node):
3334-
"""An element tree's structure without its content: tags, classes,
3335-
attribute names and the recorder's naming data-spa-* values. Phrasing
3336-
inside text is content."""
3375+
"""An element tree's structure without its content: tags, attribute names
3376+
and the recorder's naming data-spa-* values. Phrasing inside text is
3377+
content, and so is an element's class list: restyling an element that
3378+
stays (a Tailwind `bg-soft` -> `bg-deep`) is merged like its text; a
3379+
class that reshapes the plan (a card list no longer alike) still diverges
3380+
in the three-way merge."""
33373381
text = node.tag in TEXT_TAGS or any(isinstance(c, str) and c.strip() for c in node.children)
3338-
return [node.tag, sorted((node.attrs.get('class') or '').split()), sorted(node.attrs),
3382+
return [node.tag, sorted(k for k in node.attrs if k != 'class'),
33393383
sorted((k, v) for k, v in node.attrs.items() if k.startswith('data-spa-') and v and SPA_TOKEN.fullmatch(v)),
33403384
[fingerprint(c) for c in node.children if isinstance(c, Node) and not (text and phrasing(c))]]
33413385

@@ -3365,6 +3409,35 @@ def planned(manifest, dist, root):
33653409
'pages': {p['key']: read(root / 'block-plan/pages' / (p['key'] + '.json')) for p in contract['pages']}, 'dist': root / 'astro-project/dist'}
33663410

33673411

3412+
CSS_REF = re.compile(r'url\(\s*([\'"]?)([^)\'"]+)\1\s*\)|@import\s+([\'"])([^\'"]+)\3', re.I)
3413+
3414+
3415+
def carry_contract_files(contract, previous, dist):
3416+
"""Files the reviewed contract loads that the coordinator placed in the
3417+
dist (a self-hosted font sheet and its woff2) and a rebuilt dist lacks:
3418+
copied over from the previous dist, with the local files their CSS
3419+
references. Returns the dist paths carried."""
3420+
wanted = [p for key in ('styles', 'scripts', 'headScripts', 'editorStyles') for p in contract.get(key, []) if isinstance(p, str)]
3421+
carried, seen = [], set()
3422+
while wanted:
3423+
name = posixpath.normpath(wanted.pop(0).lstrip('/'))
3424+
if name in seen or name.startswith('../') or '/../' in name:
3425+
continue
3426+
seen.add(name)
3427+
source, target = local_file(previous, name), local_file(dist, name)
3428+
if target.is_file() or not source.is_file():
3429+
continue
3430+
target.parent.mkdir(parents=True, exist_ok=True)
3431+
shutil.copy2(source, target)
3432+
carried.append(name)
3433+
if name.endswith('.css'):
3434+
for match in CSS_REF.finditer(source.read_text(errors='ignore')):
3435+
ref = (match.group(2) or match.group(4) or '').split('?')[0].split('#')[0]
3436+
if ref and not re.match(r'^(?:[a-z]+:|//|#)', ref, re.I):
3437+
wanted.append(ref.lstrip('/') if ref.startswith('/') else (PurePosixPath(name).parent / ref).as_posix())
3438+
return carried
3439+
3440+
33683441
def refresh(args):
33693442
"""Carry a reviewed plan over an edit of its source dist. Pages whose
33703443
structure is unchanged keep every reviewed edit (a three-way merge with
@@ -3389,6 +3462,7 @@ def refresh(args):
33893462
raise ValueError('--previous-dist is not the dist this plan was prepared from')
33903463
after = {p['key']: digest(local_file(dist, p['file']).read_bytes()) for p in pages}
33913464
reviewed = {k: read(output / 'pages' / (k + '.json')) for k in keys}
3465+
carried_files = carry_contract_files(contract, previous, dist)
33923466
raw = read(manifest_path)
33933467
with tempfile.TemporaryDirectory() as temp:
33943468
base = planned(raw, previous, Path(temp) / 'base')
@@ -3513,7 +3587,7 @@ def refresh(args):
35133587
(manifest_path, merged_manifest, raw), (state / 'tasks.json', new_tasks, tasks), (state / 'checkpoint.json', new_checkpoint, checkpoint)):
35143588
if value != old:
35153589
write(path, value)
3516-
print(json.dumps({'ok': True, 'refreshed': refreshed, 'reopened': reopened, 'reopenedWhy': reasons, 'unchanged': unchanged, 'contract': contract_state, 'contractDiverged': diverged,
3590+
print(json.dumps({'ok': True, 'refreshed': refreshed, 'reopened': reopened, 'reopenedWhy': reasons, 'unchanged': unchanged, 'contract': contract_state, 'contractDiverged': diverged, 'carried': carried_files,
35173591
'assets': 'refreshed' if new_checkpoint.get('assetHashes') != checkpoint.get('assetHashes') else 'unchanged',
35183592
'distWritten': copied, 'plannerDrift': drift,
35193593
'tasksReopened': tasks_reopened, 'resolutionsKept': len(kept), 'resolutionsDropped': len(set(resolutions) - carried),

0 commit comments

Comments
 (0)