Skip to content

Commit 221137f

Browse files
committed
beta.33 candidate: an Author's text kept as typed, the editor writes stored blocks back unchanged, tier 1 fidelity, tier 2 runtime (from 8a46cd4)
1 parent 2634d8c commit 221137f

14 files changed

Lines changed: 287 additions & 35 deletions

‎.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-beta.32",
17+
"version": "1.0.0-beta.33",
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. Every conversion ends in your own page-by-page review; the gates catch most fidelity drift, not all of it.",
5-
"version": "1.0.0-beta.32",
5+
"version": "1.0.0-beta.33",
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-beta.32",
3+
"version": "1.0.0-beta.33",
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-beta.32
1+
1.0.0-beta.33

‎skills/html2wp/VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.0-beta.32
1+
1.0.0-beta.33

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,13 @@ def validate_evidence(root, report, theme_report=None):
348348
if not any(row.get('kind') == kind and row.get('id') == expected and row.get('width') == width
349349
and (valid_editor_visual(row, region) or valid_empty_part(row, region)) for row in report['editorVisual']):
350350
raise ValueError(f'Missing matching editor {region} visual for {expected} at {width}px')
351+
# The editor writes back what the import stored (gutenberg-verify-local.py
352+
# SERIALIZATION): a passing "serialization" row for every surface with blocks.
353+
serialized = {(row.get('kind'), row.get('id')) for row in report.get('serialization') or []
354+
if row.get('passed') is True and row.get('byteIdentical') is True and not row.get('attributeLoss')}
355+
for row in report['editor']:
356+
if row.get('count', 0) > 0 and row.get('kind') in ('page', 'post', 'templates', 'template-parts') and (row.get('kind'), row.get('id')) not in serialized:
357+
raise ValueError(f'Missing passing serialization gate for {row.get("kind")} {row.get("slug") or row.get("id")}: the editor would rewrite its stored blocks')
351358
for name in ('style.css', 'theme.json', 'templates/index.html', 'functions.php'):
352359
if not (root / name).is_file():
353360
raise ValueError('Missing ' + name)

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

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,23 @@ def local_file(root, name):
5353
return result
5454

5555

56+
# The white space HTML collapses (space, tab, line feed, form feed, carriage
57+
# return). A non-breaking space ( , U+00A0) is text: it keeps a
58+
# headline's last two words on one line, and Python's \s would eat it.
59+
HTML_SPACE = re.compile(r'[ \t\n\f\r]+')
60+
61+
5662
def collapse(text):
57-
"""Source text as HTML lays it out: a run of whitespace is one space. An
63+
"""Source text as HTML lays it out: a run of white space is one space. An
5864
element's text is edited as rich text, where a source line break (the
5965
markup's indentation) is a real line break (rich() does the same)."""
60-
return re.sub(r'\s+', ' ', text)
66+
return HTML_SPACE.sub(' ', text)
67+
68+
69+
def line(text):
70+
"""Source text as one line of content, its ends trimmed (a title, a
71+
card's summary); norm() compares, this is what WordPress stores."""
72+
return collapse(text or '').strip(' ')
6173

6274

6375
class Node:
@@ -132,6 +144,8 @@ def plain(node):
132144

133145

134146
def norm(text):
147+
"""Text for comparing: every run of white space (a non-breaking space
148+
too) is one space, so a headline's   still matches its card."""
135149
return ' '.join((text or '').split())
136150

137151

@@ -205,7 +219,7 @@ def classify(values, metas, card=False, static=False, tag=''):
205219
return 'postTitle', None
206220
if all(m.get('excerpt') and (v == norm(m['excerpt']) or (card and len(v) > 20 and norm(m['excerpt']).startswith(v.rstrip('.… ')))) for v, m in zip(values, metas)):
207221
return 'postExcerpt', None
208-
if all(m.get('categories') and v in m['categories'] for v, m in zip(values, metas)):
222+
if all(m.get('categories') and v in [norm(c) for c in m['categories']] for v, m in zip(values, metas)):
209223
return 'postTerms', None
210224
if all(m.get('readTime') and v == norm(m['readTime']) for v, m in zip(values, metas)):
211225
return 'postReadTime', None
@@ -379,7 +393,7 @@ def diff_walk(nodes, metas, overrides, values, mapper, native_title=True, stop=N
379393

380394
def record(bind, fmt, per_instance):
381395
for store, value in zip(values or [], per_instance):
382-
store.setdefault(bind, (norm(value), fmt))
396+
store.setdefault(bind, (line(value), fmt))
383397

384398
# A link that carries each post's own address or title (a share bar):
385399
# the live link carries the post being shown (bind postShare).
@@ -868,7 +882,7 @@ def rich(self, node, preserve=False):
868882
if not isinstance(child, Node):
869883
# The editor's rich text is pre-wrap: source indentation and
870884
# line breaks would show and re-wrap lines. HTML collapses them.
871-
pieces.append(html.escape(child if preserve else re.sub(r'\s+', ' ', child), quote=False))
885+
pieces.append(html.escape(child if preserve else collapse(child), quote=False))
872886
continue
873887
if child.tag not in INLINE_TAGS:
874888
return None
@@ -1400,13 +1414,13 @@ def query_block(self, node, targets, cards=None):
14001414
self.posts[key]['cardImage'] = image
14011415
# The category a card prints is its post's category, when the
14021416
# site uses that word as a category elsewhere too.
1403-
if values.get('postTerms') and not self.posts[key].get('cardTerms') and values['postTerms'][0].casefold() in self.terms:
1417+
if values.get('postTerms') and not self.posts[key].get('cardTerms') and norm(values['postTerms'][0]).casefold() in self.terms:
14041418
self.posts[key]['cardTerms'] = [values['postTerms'][0]]
14051419
# A label only the cards print (no article, category link or topic
14061420
# filter names it) fits a category as well as a badge or a person:
14071421
# it stays bound, and it is not filed as the post's category.
14081422
guessed = [(key, values['postTerms'][0]) for key, values in zip(targets, found)
1409-
if values.get('postTerms') and not self.posts[key].get('categories') and values['postTerms'][0].casefold() not in self.terms]
1423+
if values.get('postTerms') and not self.posts[key].get('categories') and norm(values['postTerms'][0]).casefold() not in self.terms]
14101424
if guessed:
14111425
self.finding('query-card-terms', 'Cards print a label no page names as a category (no article, category link or topic filter): ' + ', '.join(key + ' "' + term + '"' for key, term in guessed)[:300] + '; bound as the post categories, not recorded as them')
14121426
# The lists that show the posts in their order (prepare reads the
@@ -1420,7 +1434,7 @@ def query_block(self, node, targets, cards=None):
14201434
category = None
14211435
if not lone and len(terms) == 1 and all(values.get('postTerms') for values in found):
14221436
term = next(iter(terms))
1423-
members = {key for key, meta in self.posts.items() if term in (meta.get('categories') or meta.get('cardTerms') or [])}
1437+
members = {key for key, meta in self.posts.items() if norm(term) in [norm(c) for c in meta.get('categories') or meta.get('cardTerms') or []]}
14241438
if members == set(targets) and len(members) < len(self.posts):
14251439
category = term
14261440
for n in walk(cards[0]):
@@ -2504,7 +2518,7 @@ def __init__(self, entries, manifest):
25042518
for key, entry in self.products.items():
25052519
main = next((n for n, part, place in entry['items'] if not part and place == 'main' and isinstance(n, Node)), None)
25062520
category = first_hint(entry['root'], self.shop.get('productCategory'))
2507-
self.meta[key] = {'name': entry['h1'], 'category': norm(plain(category)) if category is not None else ''}
2521+
self.meta[key] = {'name': norm(entry['h1']), 'category': norm(plain(category)) if category is not None else ''}
25082522
self.categories = sorted({m['category'] for m in self.meta.values() if m['category']})
25092523

25102524
def target(self, mapper, node):
@@ -3200,8 +3214,9 @@ def prepare(args):
32003214
counter, lists = [0], []
32013215
# html2wp's own recorder output rides along: the interaction runtime the
32023216
# prerender emitted (never a source application script) and its entrance
3203-
# animation <style>. Source inline CSS stays a finding: it can be page
3204-
# specific and its cascade position matters.
3217+
# animation <style>. A source <style> block is page specific and its
3218+
# cascade position matters: it becomes a dist asset in that page's own
3219+
# head order (below).
32053220
runtime_scripts, head_styles, head_boots = set(), {}, {}
32063221
for page in pages:
32073222
source = local_file(dist, page['file']).read_bytes()
@@ -3309,7 +3324,7 @@ def prepare(args):
33093324
entries.append({'key': page['key'], 'page': page, 'kind': page.get('kind'), 'source': source, 'mapper': mapper, 'items': items, 'frame': frame, 'root': body,
33103325
'body_class': body.attrs.get('class', '') if body is not parser.root else '',
33113326
'sections': ['source-' + str(i + 1).zfill(4) for i in range(len(items))], 'title': title, 'description': description,
3312-
'h1': norm(plain(heading)) if heading is not None else norm(page.get('title') or ''),
3327+
'h1': line(plain(heading)) if heading is not None else line(page.get('title') or ''),
33133328
'urls': list(dict.fromkeys(u for u in urls if u and re.match(r'https?://', u)))})
33143329
# Post metadata drives card/article classification (spec 2 D3/D6).
33153330
metas = {}

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,30 @@ def test_a_search_box_in_a_mail_form_is_its_text_field(self):
10491049
self.assertIn(('unmapped-field', 'file'), items)
10501050
self.assertIn(('unmapped-field', 'hidden'), items)
10511051

1052+
def test_a_non_breaking_space_is_text_not_layout(self):
1053+
# &nbsp; keeps two words on one line (a headline's last two); HTML
1054+
# never collapses it, and neither may element or rich text.
1055+
self.assertEqual(planner.collapse('a\xa0 \n\t b'), 'a\xa0 b')
1056+
self.assertEqual(planner.line(' Notes on\xa0growth \n'), 'Notes on\xa0growth')
1057+
self.assertEqual(planner.norm('Notes on\xa0growth'), 'Notes on growth', 'compared, it is a space')
1058+
_, proposals, _ = self.plan({'index.html': '<body><div class="kicker">Step&nbsp;01</div><p>Keep the last two&nbsp;words</p>'
1059+
'<h2>Notes on strategy &amp;&nbsp;growth</h2></body>'})
1060+
kicker, para, heading = proposals['index']['blocks']
1061+
self.assertEqual(kicker['attributes']['text'], 'Step\xa001')
1062+
self.assertEqual(para['attributes']['content'], 'Keep the last two\xa0words')
1063+
self.assertEqual(heading['attributes']['content'], 'Notes on strategy &amp;\xa0growth')
1064+
1065+
def test_a_headline_s_non_breaking_space_stays_in_the_post_title(self):
1066+
# The article's headline joins its last two words; the card does not.
1067+
# WordPress stores the headline's own title, and the card still binds
1068+
# it (compared, a non-breaking space is a space).
1069+
contract, proposals, findings = self.journal_site(headline_nbsp=True)
1070+
titles = {p['key']: p['title'] for p in planner.read(self.manifest)['pages'] if p['key'] in 'abcd'}
1071+
self.assertEqual(titles['a'], 'Openers that stop the\xa0scroll')
1072+
heading = next(b for b in planner.walk_blocks(contract['templates']['home']) if b['attributes'].get('tagName') == 'h2')
1073+
self.assertEqual(heading['attributes'].get('bind'), 'postTitle')
1074+
self.assertFalse([f for f in findings['journal'] if f['code'] == 'query-card-title'])
1075+
10521076
def test_rich_text_collapses_source_whitespace_except_pre(self):
10531077
_, proposals, _ = self.plan({'index.html': self.app(body='<p class="x">Every program\n is built</p><pre>a\n b</pre>')})
10541078
para, pre = proposals['index']['blocks'][1]['innerBlocks'][:2] if proposals['index']['blocks'][1].get('innerBlocks') else proposals['index']['blocks'][1:3]
@@ -1384,7 +1408,7 @@ def test_article_template_post_metadata_and_queries(self):
13841408
self.run_cli('complete', '--task=' + task['id'], '--owner=test')
13851409
self.run_cli('finalize') # coverage stays complete and ordered
13861410

1387-
def journal_site(self, hint=False, short=('c',), article_category=True, separator='', second_page=False, topics=(), authors=False, share=False, pair='plain'):
1411+
def journal_site(self, hint=False, short=('c',), article_category=True, separator='', second_page=False, topics=(), authors=False, share=False, pair='plain', headline_nbsp=False):
13881412
"""Posts as one article sheet (head, image, prose, share bar) beside a
13891413
prev/next pair and a call to action; a listing whose cards print the
13901414
category as bare text before <time>, several posts on one day, a
@@ -1396,7 +1420,8 @@ def journal_site(self, hint=False, short=('c',), article_category=True, separato
13961420
address, and its share bar carries it and its title. pair: 'plain'
13971421
(newer then older, alike), 'sides' (← Previous is the older, Next →
13981422
the newer, each its own class) or 'newest' (every article links the
1399-
newest other post)."""
1423+
newest other post). headline_nbsp: each article's headline keeps its
1424+
last two words together (&nbsp;), which its card does not."""
14001425
posts = [('a', 'Openers that stop the scroll', 'Strategy', 'July 27, 2026'), ('b', 'A label tripled its reach', 'Case Notes', 'July 27, 2026'),
14011426
('c', 'Affiliate done honestly', 'Strategy', 'July 26, 2026'), ('d', 'Three signals worth reading', 'Insights', 'July 26, 2026')]
14021427

@@ -1437,7 +1462,7 @@ def card(post, prefix=''):
14371462
'<a href="https://www.linkedin.com/sharing/share-offsite/?url=' + url_quote(canonical, safe='') + '">LinkedIn</a>'
14381463
'<a href="mailto:?subject=' + url_quote(title, safe='') + '&amp;body=' + url_quote(canonical, safe='') + '">Email</a><a class="self" href="' + canonical + '">Link</a>')
14391464
files[key + '.html'] = page(
1440-
'<article class="sheet"><header class="head"><a class="back" href="journal.html">Blog</a><h1>' + title + '</h1>'
1465+
'<article class="sheet"><header class="head"><a class="back" href="journal.html">Blog</a><h1>' + ('&nbsp;'.join(title.rsplit(' ', 1)) if headline_nbsp else title) + '</h1>'
14411466
'<p class="dateline"><span>Published <time>' + date + '</time></span>' + ('<a class="cat" href="category/strategy.html">' + category + '</a>' if article_category else '') + '</p>' + writer(key) + '</header>'
14421467
'<div class="image"><img src="img/' + key + '.png" alt=""></div><div class="article-body">' + body + '</div>'
14431468
'<div class="share"><span>Share this</span>' + bar + '</div></article>'

‎skills/html2wp/assets/scripts/gutenberg-test-verdicts.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,30 @@ def test_failures_travel_as_keys_only(self):
8686
self.assertEqual(gates['G-roundtrip']['failedKeys'], ['front-page', 'new-page'])
8787
self.assertNotIn('/unknown-route/', json.dumps(gates))
8888

89+
def test_a_serialization_row_that_does_not_write_back_fails_the_roundtrip(self):
90+
# What the editor's first save writes back: a page by its key, a
91+
# template or part by its literal template-<slug> / part-<slug>.
92+
rows = [{'kind': 'page', 'id': 7, 'slug': 'front-page', 'path': '/', 'passed': True, 'byteIdentical': True, 'attributeLoss': []},
93+
{'kind': 'page', 'id': 8, 'slug': 'about', 'path': '/about/', 'passed': False, 'byteIdentical': False, 'attributeLoss': []},
94+
{'kind': 'templates', 'id': 'demo//single', 'passed': False, 'byteIdentical': True, 'attributeLoss': [{'key': 'foo'}]},
95+
{'kind': 'template-parts', 'id': 'demo//header', 'passed': True, 'byteIdentical': True, 'attributeLoss': []}]
96+
gates = self.payload(report(serialization=rows))
97+
self.assertEqual((gates['G-roundtrip']['verdict'], gates['G-roundtrip']['failedKeys']), ('failed', ['about', 'template-single']))
98+
self.assertEqual(gates['G-editor']['verdict'], 'passed', 'the editor gate reads its own rows')
99+
for row in rows:
100+
row['passed'] = True
101+
self.assertEqual(self.payload(report(serialization=rows))['G-roundtrip'], {'gate': 'G-roundtrip', 'verdict': 'passed', 'pages': 2})
102+
# Without the save/reload run a failing row still fails the gate; a passing one proves nothing.
103+
rows[3]['passed'] = False
104+
bare = report(serialization=rows)
105+
for e in bare['editor']:
106+
e.pop('roundtrip')
107+
bare.pop('newPost'); bare.pop('newPage')
108+
gate = self.payload(bare)['G-roundtrip']
109+
self.assertEqual((gate['verdict'], gate['failedKeys']), ('failed', ['part-header']))
110+
rows[3]['passed'] = True
111+
self.assertEqual(self.payload(bare)['G-roundtrip']['verdict'], 'not-run')
112+
89113
def test_absent_sections_are_not_passes(self):
90114
r = report(editorVisual=[], newPage=None, preview=None)
91115
r.pop('newPage'); r.pop('preview')

0 commit comments

Comments
 (0)