Skip to content

Commit 2830e01

Browse files
committed
Gutenberg: a site search is WordPress's search; script-wrapped text has a documented editor lever (from 38b7526)
1 parent 96ed9b0 commit 2830e01

6 files changed

Lines changed: 146 additions & 3 deletions

File tree

‎skills/html2wp/assets/scripts/block-schema.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@
320320
"buttonText": "string",
321321
"buttonPosition": "string",
322322
"buttonUseIcon": "boolean",
323+
"query": "searchQuery",
323324
"lock": "lock",
324325
"metadata": "metadata",
325326
"className": "string",
@@ -1201,6 +1202,9 @@
12011202
"rows": {
12021203
"check": "rows"
12031204
},
1205+
"searchQuery": {
1206+
"check": "searchQuery"
1207+
},
12041208
"string": {
12051209
"check": "string"
12061210
},

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,11 @@ def field(self, node, label):
934934
label_attrs = label if isinstance(label, dict) else {'text': label}
935935
label = label_attrs.get('text', '')
936936
kind = node.tag if node.tag in {'textarea', 'select'} else node.attrs.get('type', 'text')
937+
# A search box in a form that is no site search (search_form) is the
938+
# form's text field; rules on input[type=search] no longer match it.
939+
if kind == 'search':
940+
self.finding('field-type', 'search field kept as a text field: ' + (node.attrs.get('name') or node.attrs.get('id') or ''))
941+
kind = 'text'
937942
if kind not in {'text', 'email', 'tel', 'url', 'number', 'textarea', 'select', 'checkbox', 'radio'}:
938943
self.finding('unmapped-field', kind)
939944
return None
@@ -965,6 +970,80 @@ def field(self, node, label):
965970
self.finding('field-default', 'Preserve textarea default content: ' + name)
966971
return {'name': 'h2wp/field', 'attributes': result}
967972

973+
def search_form(self, node):
974+
"""core/search for a site search, else None: a GET to the front page
975+
with one box named `s`, the way WordPress searches (a WordPress export
976+
keeps its theme's form). No mail form: WordPress answers it with its
977+
search results. Its hidden inputs are the search's own parameters
978+
(`post_type=post`: notes only). An icon or text beside the box keeps
979+
the form's classes on an element around the search, so the design
980+
lays them out as it did."""
981+
if (node.attrs.get('method') or 'get').strip().lower() != 'get':
982+
return None
983+
# The front page: `/`, a path to it, or the site's own address as an
984+
# export writes it (home_url(): any host, no path).
985+
action, home = (node.attrs.get('action') or '').strip(), self.resolve_quiet('/')
986+
address = urlsplit(action)
987+
if action != '/' and not (address.scheme in ('http', 'https') and address.path in ('', '/') and not address.query) \
988+
and not (action and home.startswith('page:') and self.resolve_quiet(action).split('#')[0].split('?')[0] == home):
989+
return None
990+
kind = lambda n: (n.attrs.get('type') or ('submit' if n.tag == 'button' else 'text')).lower()
991+
controls = [n for n in walk(node) if n.tag in ('input', 'select', 'textarea', 'button')]
992+
box = [n for n in controls if n.tag == 'input' and kind(n) in ('search', 'text') and n.attrs.get('name') == 's']
993+
hidden = [n for n in controls if n.tag == 'input' and kind(n) == 'hidden']
994+
submit = [n for n in controls if (n.tag == 'button' and kind(n) == 'submit') or (n.tag == 'input' and kind(n) in ('submit', 'image'))]
995+
query = {n.attrs.get('name') or '': n.attrs.get('value') or '' for n in hidden}
996+
if len(box) != 1 or len(submit) > 1 or len(box) + len(hidden) + len(submit) != len(controls) or len(query) != len(hidden) \
997+
or any(not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', k, re.I) or k == 's' or len(v) > 200 for k, v in query.items()):
998+
return None
999+
field = box[0]
1000+
labels = [n for n in walk(node) if n.tag == 'label']
1001+
named = self.labels.get(field.attrs.get('id')) if field.attrs.get('id') else None
1002+
words = norm(plain(labels[0])) if labels else norm((named or {}).get('text') or '')
1003+
attrs = {'label': words or norm(field.attrs.get('aria-label') or '') or norm(field.attrs.get('placeholder') or '') or 'Search',
1004+
'showLabel': bool(labels), 'buttonPosition': 'no-button'}
1005+
if field.attrs.get('placeholder'):
1006+
attrs['placeholder'] = field.attrs['placeholder']
1007+
if submit:
1008+
button = submit[0]
1009+
text = norm(plain(button)) if button.tag == 'button' else norm(button.attrs.get('value') or '')
1010+
attrs.update({'buttonPosition': 'button-outside', 'buttonText': text or 'Search'})
1011+
if button.tag == 'button' and not text and any(n.tag == 'svg' for n in walk(button)):
1012+
attrs['buttonUseIcon'] = True
1013+
self.finding('search-button', 'core/search draws its own button (wp-block-search__button): the source button\'s markup and classes "' + (button.attrs.get('class') or '') + '" are not carried; review its style')
1014+
if labels:
1015+
self.finding('search-label', 'core/search draws its own label (wp-block-search__label): the source label\'s classes "' + (labels[0].attrs.get('class') or '') + '" are not carried; review its style')
1016+
if query:
1017+
attrs['query'] = query
1018+
own, extra = self.attrs(node, ('action', 'method', 'role'))
1019+
if extra:
1020+
self.finding('search-attributes', 'core/search keeps only the form\'s classes and id: ' + json.dumps(extra, sort_keys=True))
1021+
parts = {id(n) for n in box + hidden + submit + labels}
1022+
decoration = [c for c in node.children if (isinstance(c, str) and c.strip()) or (isinstance(c, Node) and id(c) not in parts)]
1023+
search = {'name': 'core/search', 'attributes': attrs}
1024+
if not decoration:
1025+
search['attributes'].update(own)
1026+
return search
1027+
if any(not any(c is n for c in node.children) for n in box + hidden + submit + labels):
1028+
# The box sits inside the form's own markup: the search stands for
1029+
# the whole form, and that markup is not kept.
1030+
self.finding('search-markup', 'core/search stands for the whole form; the markup around its box is not kept: ' + ', '.join(sorted({c.tag for c in decoration if isinstance(c, Node)})))
1031+
search['attributes'].update(own)
1032+
return search
1033+
# The box was the form's own item: the search form takes its place and
1034+
# its room (a box that fills the pill fills it still; with no room to
1035+
# spare it keeps its size).
1036+
search['attributes']['className'] = self.style_class('flex:1 1 auto;min-width:0')
1037+
inner = []
1038+
for child in node.children:
1039+
if isinstance(child, Node) and child is field:
1040+
inner.append(search)
1041+
elif (isinstance(child, str) and child.strip()) or (isinstance(child, Node) and id(child) not in parts):
1042+
block = self.block(child)
1043+
if block:
1044+
inner.append(block)
1045+
return {'name': 'h2wp/element', 'attributes': {**own, 'tagName': 'div'}, 'innerBlocks': inner}
1046+
9681047
def simple_attrs(self, node, extra=()):
9691048
"""class/id/style only (no findings); None when other attributes exist."""
9701049
if set(node.attrs) - {'class', 'id', 'style', *extra}:
@@ -1613,6 +1692,9 @@ def block_default(self, node, texts=None):
16131692
self.finding('unmapped-element', tag)
16141693
return None
16151694
if tag == 'form':
1695+
search = self.search_form(node)
1696+
if search:
1697+
return search
16161698
attrs, extra = self.attrs(node, ('action', 'method', 'data-spa-success', 'data-spa-validate'))
16171699
# The feedback the source app showed on a successful submit
16181700
# (stage -1 records it; see prerender-spa.py), shown by the

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,45 @@ def test_empty_wrappers_stay_elements(self):
10101010
self.assertEqual(section['name'], 'core/group')
10111011
self.assertEqual([b['name'] for b in section['innerBlocks']], ['h2wp/element', 'core/paragraph'])
10121012

1013+
def test_a_site_search_form_is_the_wordpress_search(self):
1014+
# A GET to the front page with a box named s is how WordPress searches
1015+
# (a WordPress export keeps its theme's form): core/search, never a
1016+
# mail form. The hidden post_type is the search's own parameter, and
1017+
# the icon beside the box keeps the form's classes around it.
1018+
icon = '<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle></svg>'
1019+
_, proposals, findings = self.plan({'index.html': '<body><div class="bar"><h1>Home</h1></div></body>', 'notes.html': '<body><div class="bar">'
1020+
'<form class="topic-search" role="search" method="get" action="/">' + icon + '<input type="search" name="s" placeholder="Search notes" aria-label="Search notes">'
1021+
'<input type="hidden" name="post_type" value="post"></form>'
1022+
'<form class="plain" action="index.html"><input type="text" name="s"><button type="submit" class="btn">Go</button></form>'
1023+
'<form class="export" action="https://example.com/"><input type="search" name="s"></form></div></body>'})
1024+
wrapper, plain_search, exported = [dict(b, attributes={k: v for k, v in b['attributes'].items() if k != 'metadata'}) for b in proposals['notes']['blocks']]
1025+
self.assertEqual((wrapper['name'], wrapper['attributes']), ('h2wp/element', {'className': 'topic-search', 'tagName': 'div'}))
1026+
self.assertEqual([b['name'] for b in wrapper['innerBlocks']], ['h2wp/icon', 'core/search'])
1027+
search = dict(wrapper['innerBlocks'][1]['attributes'])
1028+
room = search.pop('className')
1029+
self.assertEqual(search, {'label': 'Search notes', 'showLabel': False, 'buttonPosition': 'no-button', 'placeholder': 'Search notes', 'query': {'post_type': 'post'}})
1030+
self.assertIn('.' + room + '{flex:1 1 auto;min-width:0}', (self.dist / 'assets/gutenberg-inline.css').read_text(), 'the search form takes the box\'s room')
1031+
# The site's own address, as a WordPress export writes home_url().
1032+
self.assertEqual((exported['name'], exported['attributes']['className']), ('core/search', 'export'))
1033+
self.assertEqual({k: plain_search[k] for k in ('name', 'attributes')}, {'name': 'core/search', 'attributes': {'label': 'Search', 'showLabel': False, 'buttonPosition': 'button-outside', 'buttonText': 'Go', 'className': 'plain'}})
1034+
codes = [f['code'] for f in findings['notes']]
1035+
self.assertEqual(codes, ['search-button'], 'no mail-form finding, no dropped field')
1036+
self.assertNotIn('h2wp/form', json.dumps(proposals['notes']))
1037+
1038+
def test_a_search_box_in_a_mail_form_is_its_text_field(self):
1039+
# Not a site search (a POST, another action, another name): the box is
1040+
# the form's text field, reported; a kind no field draws stays unmapped.
1041+
_, proposals, findings = self.plan({'index.html': '<body><h1>Home</h1></body>', 'contact.html': '<body>'
1042+
'<form method="post" action="/send"><input type="search" name="topic"><input type="file" name="cv"><input type="hidden" name="ref" value="x"><button type="submit">Send</button></form>'
1043+
'<form method="get" action="/catalog"><input type="search" name="q"></form></body>'})
1044+
mail, other = proposals['contact']['blocks']
1045+
self.assertEqual((mail['name'], other['name']), ('h2wp/form', 'h2wp/form'))
1046+
self.assertEqual([(b['attributes'].get('name'), b['attributes'].get('type')) for b in mail['innerBlocks'] if b['name'] == 'h2wp/field'], [('topic', 'text')])
1047+
items = [(f['code'], i['detail']) for f in findings['contact'] for i in planner.finding_items(f)]
1048+
self.assertIn(('field-type', 'search field kept as a text field: topic'), items)
1049+
self.assertIn(('unmapped-field', 'file'), items)
1050+
self.assertIn(('unmapped-field', 'hidden'), items)
1051+
10131052
def test_rich_text_collapses_source_whitespace_except_pre(self):
10141053
_, proposals, _ = self.plan({'index.html': self.app(body='<p class="x">Every program\n is built</p><pre>a\n b</pre>')})
10151054
para, pre = proposals['index']['blocks'][1]['innerBlocks'][:2] if proposals['index']['blocks'][1].get('innerBlocks') else proposals['index']['blocks'][1:3]

‎skills/html2wp/assets/scripts/gutenberg_block_schema.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@ def _fieldMap(self, value, at, kind):
367367
if not isinstance(value, dict) or any(not re.fullmatch(r'[a-z0-9_-]{1,64}', k, AI) or not isinstance(v, str) or _u16(v) > 64 for k, v in value.items()):
368368
_raise(f'{at}: expected field name → plugin field name')
369369

370+
def _searchQuery(self, value, at, kind):
371+
if not isinstance(value, dict) or any(not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', k, AI) or k == 's' or not isinstance(v, str) or _u16(v) > 200 for k, v in value.items()):
372+
_raise(f'{at}: expected search parameters (name → text, not s)')
373+
370374
def _formSuccess(self, value, at, kind):
371375
_strict(value, ['kind', 'html', 'list', 'region', 'ms'], at)
372376
html = value.get('html', UNDEFINED)

‎skills/html2wp/references/gutenberg.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ paragraph/heading/list blocks, YouTube/Vimeo iframes to `core/embed`,
4646
`video`/`audio`/`table`/`picture`/`pre` to their core blocks, names top-level
4747
sections through `metadata.name`, and writes literal `:root` color, font and
4848
font-size custom properties to `themeJson.settings` presets.
49+
A site search (a GET to the front page with one box named `s`, the way
50+
WordPress searches; a WordPress export keeps its theme's form) becomes
51+
`core/search`, never a mail form: WordPress answers it with its search
52+
results. Its hidden inputs become the block's `query` (`post_type=post`), and
53+
an icon or text beside the box keeps the form's classes on an element around
54+
it. `search-button`/`search-label` note a source button or label the block
55+
draws its own way. A search box in any other form is that form's text field
56+
(`field-type`); a kind no field draws (file, date, hidden…) stays
57+
`unmapped-field`.
4958

5059
For blogs the planner also derives the article and listing templates:
5160
it aligns all post pages, keeps identical sections as template content,

0 commit comments

Comments
 (0)