Skip to content

Commit 35a778b

Browse files
iOSDevSKclaude
andcommitted
Release 1.0.0-beta.28 (from c7235a6)
Synced from iOSDevSK/html2wp-sub-protected feature/dual-target @ c7235a6cc8d91c96c41ba5d07fd4ebf23c908a8c 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 bfd586f commit 35a778b

34 files changed

Lines changed: 3283 additions & 169 deletions

‎.github/workflows/gate.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ jobs:
161161
-o -name 'empty-state.mjs' -o -name 'listing-order.mjs' -o -name 'menu-categories.mjs' \
162162
-o -name 'not-found.mjs' -o -name 'pager.mjs' -o -name 'related-rule.mjs' \
163163
-o -name 'reveal-css.mjs' -o -name 'shop-design.mjs' -o -name 'source-index.mjs' \
164-
-o -name 'lead-cards.mjs' -o -name 'article-head.mjs' -o -name 'cascade-slot.mjs' -o -name 'utility-body.mjs' -o -name 'owned-selectors.mjs' \
164+
-o -name 'lead-cards.mjs' -o -name 'article-head.mjs' -o -name 'cascade-slot.mjs' -o -name 'utility-body.mjs' -o -name 'owned-selectors.mjs' -o -name 'part-band.mjs' -o -name 'selector-tree.mjs' \
165165
-o -name 'visual-edit.zip' -o -name 'THREAT-MODEL.md' \
166166
-o -name 'gotcha-classification.md' -o -type d -name templates \) -print)
167167
if [ -n "$found" ]; then

‎skills/html2wp/assets/scripts/audit-woo-coverage.py‎

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,23 @@ def fmt(v):
238238
return fmt(p.get("price", 0)), fmt(p.get("regular_price", 0))
239239

240240

241+
REVIEWS_SEL = "#reviews, #review_form, .woocommerce-Reviews"
242+
243+
244+
def html_reviews_verdict(shown_before, shown_after):
245+
"""'ok' when reviews work on the HTML theme's product page, else the GAP.
246+
247+
shown_before: the reviews section renders for the product as it is (a
248+
design without reviews shows none until one exists — not a gap);
249+
shown_after: an approved test review rendered (None = not exercised, no
250+
wp-cli)."""
251+
if shown_after is True:
252+
return "ok"
253+
if shown_after is None:
254+
return "ok" if shown_before else "unverified"
255+
return "an approved review is not shown on its product page"
256+
257+
241258
def enable_cod(run):
242259
"""Turn cash on delivery on for the audit's order; returns what to restore.
243260
@@ -488,13 +505,37 @@ def add_to_cart(p, row, expect_ok=True):
488505
gap("my-account did not render a login form", "my-account")
489506

490507
anyrow = by["simple"] or by["variable"]
491-
if anyrow:
508+
reviews_on = wpcli("option get woocommerce_enable_reviews") if args.wp_cli else "yes"
509+
if anyrow and reviews_on != "no":
510+
# A design without reviews shows none until a product HAS one, so
511+
# the check is that an approved review appears once it exists —
512+
# and that a product without one shows exactly the design.
492513
page.goto(product_url(anyrow), wait_until="networkidle")
493514
page.wait_for_timeout(1200)
494-
if "review" in page.inner_text("body").lower():
495-
ok("a product page offers reviews", "reviews")
515+
before = bool(page.query_selector(REVIEWS_SEL))
516+
after = None
517+
if args.wp_cli and anyrow.get("id"):
518+
cid = wpcli(f"comment create --comment_post_ID={int(anyrow['id'])} --comment_type=review "
519+
"--comment_approved=1 --comment_author='Coverage Audit' "
520+
"--comment_author_email=audit@coverage.test "
521+
"--comment_content='Audit review: soft and warm.' --porcelain")
522+
if cid:
523+
wpcli(f"comment meta add {int(cid)} rating 5")
524+
wpcli(f"eval 'wc_delete_product_transients({int(anyrow['id'])}); "
525+
f"WC_Comments::clear_transients({int(anyrow['id'])});'")
526+
page.goto(product_url(anyrow), wait_until="networkidle")
527+
page.wait_for_timeout(1200)
528+
box = page.query_selector(REVIEWS_SEL)
529+
after = bool(box) and "Audit review: soft and warm." in box.inner_text()
530+
wpcli(f"comment delete {int(cid)} --force")
531+
wpcli(f"eval 'WC_Comments::clear_transients({int(anyrow['id'])});'")
532+
verdict = html_reviews_verdict(before, after)
533+
if verdict == "ok":
534+
ok("an approved review shows on its product page" if after else "a product page offers reviews", "reviews")
535+
elif verdict == "unverified":
536+
note("reviews: none render yet (the design has none) — pass --wp-cli to prove an approved one shows")
496537
else:
497-
gap("reviews are enabled in Woo but rendered nowhere", "reviews")
538+
gap(verdict, "reviews")
498539

499540
page.goto(f"{BASE}/cart/", wait_until="networkidle")
500541
page.wait_for_timeout(2500)
@@ -610,6 +651,21 @@ def spec_frozen(specs, rows):
610651
return not all(squash(specs[0]) in own_text(r) for r in rows)
611652

612653

654+
def reviews_verdict(in_template, shown_before, shown_after):
655+
"""'ok' when reviews work on a product page, else the GAP text.
656+
657+
in_template: the product template places a reviews block (None = unknown,
658+
no wp-cli); shown_before: reviews render with the product as it is;
659+
shown_after: an approved test review rendered (None = not exercised)."""
660+
if in_template is False:
661+
return "reviews are enabled in Woo but the product template has no reviews block"
662+
if shown_after is True or (shown_after is None and shown_before):
663+
return "ok"
664+
if shown_after is None:
665+
return "reviews are enabled in Woo but rendered nowhere"
666+
return "an approved review is not shown on its product page"
667+
668+
613669
def soldout_ids(row):
614670
"""The product and every variation of it — what a basket line can carry."""
615671
return {row.get("id")} | {v.get("id") for v in row.get("variations") or [] if isinstance(v, dict)}
@@ -1098,16 +1154,36 @@ def click_add():
10981154
else:
10991155
gap("no published page renders the WooCommerce account form", "my-account")
11001156

1157+
# A block theme may show its reviews block only once a product has a
1158+
# review (a design without one stays as drawn), so "reviews work" is:
1159+
# enabled in WooCommerce, placed in the product template, and a real
1160+
# approved review rendered on the page (then deleted again).
11011161
reviews_on = wpcli("option get woocommerce_enable_reviews") if args.wp_cli else "yes"
11021162
if anyrow and reviews_on != "no":
1163+
REVIEWS = ("#reviews, .woocommerce-Reviews, .wp-block-woocommerce-product-reviews, "
1164+
".wp-block-woocommerce-product-details")
1165+
in_template = None
1166+
if args.wp_cli:
1167+
found = wpcli("eval \"$t = get_block_template(get_stylesheet() . '//single-product'); echo $t ? (int) (strpos($t->content, 'wp:woocommerce/product-reviews') !== false || strpos($t->content, 'wp:woocommerce/product-details') !== false) : 2;\"")
1168+
in_template = found == "1" if found in ("0", "1") else None
11031169
go(anyrow["permalink"])
1104-
if page.query_selector("#reviews, #review_form, .woocommerce-Reviews, "
1105-
".wp-block-woocommerce-product-reviews, "
1106-
".wp-block-woocommerce-product-review-form, "
1107-
".wp-block-woocommerce-product-details"):
1108-
ok("a product page offers reviews", "reviews")
1170+
before = page.query_selector(REVIEWS) is not None
1171+
after, review = None, None
1172+
if args.wp_cli:
1173+
marker = "Woo audit review " + str(anyrow["id"])
1174+
cid = wpcli(f"comment create --comment_post_ID={int(anyrow['id'])} --comment_type=review --comment_approved=1 "
1175+
f"--comment_author=Audit --comment_author_email=audit@example.com --comment_content={shlex.quote(marker)} --porcelain")
1176+
if cid and cid.isdigit():
1177+
cleanup.append(f"comment delete {cid} --force")
1178+
go(anyrow["permalink"])
1179+
block = page.query_selector(REVIEWS)
1180+
after = bool(block and marker in (block.inner_text() or ""))
1181+
wpcli(f"comment delete {cid} --force")
1182+
verdict = reviews_verdict(in_template, before, after)
1183+
if verdict == "ok":
1184+
ok("a product page offers reviews" + (" (rendered once a review exists)" if after and not before else ""), "reviews")
11091185
else:
1110-
gap("reviews are enabled in Woo but rendered nowhere", "reviews")
1186+
gap(verdict, "reviews")
11111187

11121188
if cart_url:
11131189
go(cart_url, 2500)

‎skills/html2wp/assets/scripts/capture-chrome.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from pathlib import Path
3737

3838
from playwright.sync_api import sync_playwright
39+
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
40+
from manifest_paths import input_dir_of, workspace_of # noqa: E402
3941

4042
ap = argparse.ArgumentParser()
4143
ap.add_argument("--manifest", required=True)
@@ -44,7 +46,7 @@
4446
args = ap.parse_args()
4547

4648
MF = json.loads(Path(args.manifest).read_text())
47-
WS = Path(MF["workspace"]).resolve()
49+
WS = workspace_of(MF, args.manifest)
4850
DIST = Path(args.dist or (WS / "astro-project" / "dist")).resolve()
4951
OUT = Path(args.out or (WS / "chrome-at-rest")).resolve()
5052
OUT.mkdir(parents=True, exist_ok=True)

‎skills/html2wp/assets/scripts/chrome-groups.mjs‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
3232
import { join, resolve } from 'node:path';
3333
import { entryKind, safePathUnderRoot } from './lib/safe-path.mjs';
34+
import { inputDirOf, workspaceOf } from './lib/manifest-paths.mjs';
3435

3536
// A tag's attribute run may legitimately contain '>' inside a quoted value
3637
// (Tailwind: class="[&>svg]:size-3").
@@ -406,7 +407,7 @@ const groupKey = (markup, volatile, pageFile) => rootTargets(collapseClassWhites
406407
* per zone via findNavZone(); the region-wide call below is only for the
407408
* whole-region default when no zone is declared.
408409
*/
409-
export function regionNavStates(rawVariants) {
410+
export function regionNavStates(rawVariants, children = new Map()) {
410411
// Per href, per class value, the SET OF PAGES that wear it — not a count.
411412
// An active/resting pair is one href appearing with two class values on
412413
// DIFFERENT pages: resting everywhere, active on the one page it points at.
@@ -452,6 +453,13 @@ export function regionNavStates(rawVariants) {
452453
// neither value, or both).
453454
const pageOf = (x) => String(x || '').split(/[?#]/)[0].replace(/^\.?\/+/, '')
454455
.replace(/(^|\/)index\.html?$/i, '$1').replace(/\.html?$/i, '').replace(/\/+$/, '');
456+
// A page is a CHILD of a link's target when its address extends the
457+
// target's (blog/an-article under blog), or when the caller names it as one
458+
// — a blog whose articles sit beside the listing (journal-hook.html next to
459+
// journal.html) is a section all the same, and only the manifest knows it.
460+
const childPages = new Map([...children].map(([t, pages]) => [pageOf(t), new Set([...pages].map(pageOf))]));
461+
const isChild = (pg, target) => !!target && (pageOf(pg).startsWith(`${target}/`)
462+
|| (childPages.get(target) || new Set()).has(pageOf(pg)));
455463
for (const [href, t] of tally) {
456464
if (t.size < 2) continue;
457465
const target = pageOf(href);
@@ -464,14 +472,32 @@ export function regionNavStates(rawVariants) {
464472
// Beyond the target and UNDER it (blog/an-article for blog): a value worn
465473
// on unrelated pages (a 404 styled differently) is a variant, not a
466474
// section, and must not switch the rule on.
467-
if (worn.length === 1 && target && [...worn[0][1]].some((pg) => pageOf(pg).startsWith(`${target}/`))) section = true;
475+
if (worn.length === 1 && target && [...worn[0][1]].some((pg) => isChild(pg, target))) section = true;
468476
const candidates = ranked.slice(1).filter(([, pages]) => disjoint(pages, restPages));
469477
if (!candidates.length) continue; // two links to one target, not a state pair
470478
rests.set(restValue, (rests.get(restValue) || 0) + restPages.size);
471479
for (const [value, pages] of candidates) {
472480
actives.set(value, (actives.get(value) || 0) + pages.size);
473481
}
474482
}
483+
// The same section highlight, carried by `aria-current` instead of a class
484+
// — `.nav-links a[aria-current="page"]::after` drawing the underline. The
485+
// class vote above never sees it, and the runtime writes aria-current only
486+
// on the link to the page being served, so a design that marked the
487+
// listing's link on its articles lost that on every article. Switched on
488+
// ONLY when the source did it: some link carries aria-current on a page
489+
// that is its target's child. The value it carried there is kept, since a
490+
// design may style "page" and "true" differently.
491+
let ariaSection = '';
492+
const ARIA = /\saria-current=(["'])([^"']*)\1/i;
493+
for (const v of rawVariants) {
494+
for (const m of v.markup.matchAll(LINK)) {
495+
const aria = ARIA.exec(m[0]);
496+
if (!aria || aria[2].toLowerCase() === 'false') continue;
497+
const target = pageOf(fold(m[2], v.pages[0]));
498+
if (v.pages.some((pg) => isChild(pg, target))) ariaSection = ariaSection || aria[2];
499+
}
500+
}
475501
const ranked = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]);
476502
const top = (m) => (m.size ? ranked(m)[0][0] : '');
477503
// A menu with two LEVELS legitimately has one active/resting pair PER LEVEL
@@ -489,7 +515,7 @@ export function regionNavStates(rawVariants) {
489515
const restVocabularies = restRanked
490516
.filter(([, n]) => n >= (restRanked[0]?.[1] || 0) / 2)
491517
.map(([value, pages]) => ({ value, pages }));
492-
return { active: top(actives), rest: top(rests), restVocabularies, section };
518+
return { active: top(actives), rest: top(rests), restVocabularies, section, ariaSection };
493519
}
494520

495521
/**
@@ -509,7 +535,7 @@ export function regionNavStates(rawVariants) {
509535
export function computeChromeGroups(MF, WS) {
510536
const astroReport = JSON.parse(readFileSync(join(WS, 'astro-report.json'), 'utf8'));
511537
const fragDir = join(WS, 'astro-project', 'src', 'fragments', 'chrome');
512-
const VOL = volatileClasses(resolve(MF.input.dir));
538+
const VOL = volatileClasses(inputDirOf(MF, join(WS, 'conversion-manifest.json')));
513539
const variants = astroReport.chromeVariants || [];
514540
const manifestPages = Array.isArray(MF.pages)
515541
? new Set(MF.pages.map((page) => page.file))
@@ -583,7 +609,7 @@ const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv
583609
if (invokedDirectly && process.argv.some((a) => a.startsWith('--manifest='))) {
584610
const manifestPath = process.argv.find((a) => a.startsWith('--manifest=')).slice(11);
585611
const MF = JSON.parse(readFileSync(manifestPath, 'utf8'));
586-
const WS = resolve(MF.workspace);
612+
const WS = workspaceOf(MF, manifestPath);
587613
const { regions } = computeChromeGroups(MF, WS);
588614
// Markup stays out of the JSON — the capture step reads chrome from the
589615
// LIVE page, and make-theme imports this module directly. The JSON names

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131

3232
from playwright.sync_api import sync_playwright
3333
from PIL import Image, ImageDraw
34+
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
35+
from manifest_paths import input_dir_of, workspace_of # noqa: E402
3436

3537
ap = argparse.ArgumentParser()
3638
ap.add_argument("--manifest", required=True)
@@ -44,7 +46,7 @@
4446
args = ap.parse_args()
4547

4648
MF = json.loads(Path(args.manifest).read_text())
47-
WS = Path(MF["workspace"]).resolve()
49+
WS = workspace_of(MF, args.manifest)
4850
# A native Gutenberg (v2) manifest: WordPress routes come from each page's
4951
# kind and slug, not from its key; fragments are not pages.
5052
V2 = MF.get("schema") == "html2wp/2" or MF.get("target") == "gutenberg"

‎skills/html2wp/assets/scripts/detect-collections.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040

4141
sys.path.insert(0, str(Path(__file__).parent / "lib"))
4242
from collection_coverage import uncovered
43+
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
44+
from manifest_paths import input_dir_of, workspace_of # noqa: E402
4345

4446
ap = argparse.ArgumentParser()
4547
ap.add_argument("--manifest", required=True)
@@ -48,7 +50,7 @@
4850

4951
MANIFEST = Path(args.manifest).resolve()
5052
MF = json.loads(MANIFEST.read_text())
51-
WS = Path(MF["workspace"]).resolve()
53+
WS = workspace_of(MF, MANIFEST)
5254
DIST = Path(args.dist or (WS / "astro-project" / "dist")).resolve()
5355

5456
# From the function expression itself — playwright decides "expression or

‎skills/html2wp/assets/scripts/gate-a-bisect.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ MF="$WS/conversion-manifest.json"
7070
U="${U:-$WS/input-untouched}"
7171
[ -d "$U" ] || { echo "no untouched original at $U" >&2; exit 64; }
7272
if [ -z "$W" ]; then
73-
W="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["input"]["dir"])' "$MF")" \
73+
W="$(python3 -c 'import json,sys; sys.path.insert(0, sys.argv[2]); from manifest_paths import input_dir_of; print(input_dir_of(json.load(open(sys.argv[1])), sys.argv[1]))' "$MF" "$(dirname "$0")/lib")" \
7474
|| { echo "cannot read input.dir from $MF — pass --working" >&2; exit 64; }
7575
fi
7676
[ -d "$W" ] || { echo "no working copy at $W" >&2; exit 64; }

0 commit comments

Comments
 (0)