Skip to content

Commit fc62b3f

Browse files
authored
Add CSS selector support (#5)
Experimental support for selectors in WordPress/wordpress-develop#7857
1 parent 2769d75 commit fc62b3f

File tree

6 files changed

+155
-17
lines changed

6 files changed

+155
-17
lines changed

html-api-debugger/html-api-debugger.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function () {
4545
$html = $request->get_json_params()['html'] ?: '';
4646
$options = array(
4747
'context_html' => $request->get_json_params()['contextHTML'] ?: null,
48+
'selector' => $request->get_json_params()['selector'] ?: null,
4849
);
4950
return prepare_html_result_object( $html, $options );
5051
},
@@ -112,6 +113,7 @@ function () {
112113

113114
$options = array(
114115
'context_html' => null,
116+
'selector' => null,
115117
);
116118

117119
$html = '';
@@ -122,6 +124,9 @@ function () {
122124
if ( isset( $_GET['contextHTML'] ) && is_string( $_GET['contextHTML'] ) ) {
123125
$options['context_html'] = stripslashes( $_GET['contextHTML'] );
124126
}
127+
if ( isset( $_GET['selector'] ) && is_string( $_GET['selector'] ) ) {
128+
$options['selector'] = stripslashes( $_GET['selector'] );
129+
}
125130
// phpcs:enable WordPress.Security.NonceVerification.Recommended
126131

127132
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

html-api-debugger/html-api-integration.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
function get_supports(): array {
1313
return array(
1414
'create_fragment_advanced' => method_exists( WP_HTML_Processor::class, 'create_fragment_at_current_node' ),
15+
'selectors' =>
16+
class_exists( '\WP_CSS_Complex_Selector_List' )
17+
|| class_exists( '\WP_CSS_Compound_Selector_List' ),
1518
);
1619
}
1720

@@ -70,6 +73,25 @@ function get_normalized_html( string $html, array $options ): ?string {
7073
* @param array $options The options.
7174
*/
7275
function get_tree( string $html, array $options ): array {
76+
/**
77+
* Messages generated during parse.
78+
*
79+
* @var string[]
80+
*/
81+
$warnings = array();
82+
$selector = null;
83+
if ( isset( $options['selector'] ) && class_exists( '\WP_CSS_Complex_Selector_List' ) ) {
84+
$selector = \WP_CSS_Complex_Selector_List::from_selectors( $options['selector'] );
85+
if ( null === $selector ) {
86+
$warnings[] = 'The provided selector is invalid or unsupported.';
87+
}
88+
} elseif ( isset( $options['selector'] ) && class_exists( '\WP_CSS_Compound_Selector_List' ) ) {
89+
$selector = \WP_CSS_Compound_Selector_List::from_selectors( $options['selector'] );
90+
if ( null === $selector ) {
91+
$warnings[] = 'The provided selector is invalid or unsupported.';
92+
}
93+
}
94+
7395
$processor_state = new ReflectionProperty( WP_HTML_Processor::class, 'state' );
7496
$processor_state->setAccessible( true );
7597

@@ -225,6 +247,8 @@ function get_tree( string $html, array $options ): array {
225247
$document_title = $processor->get_modifiable_text();
226248
}
227249

250+
$matches = $selector !== null && $selector->matches( $processor );
251+
228252
$attributes = array();
229253
$attribute_names = $processor->get_attribute_names_with_prefix( '' );
230254
if ( null !== $attribute_names ) {
@@ -261,6 +285,7 @@ function get_tree( string $html, array $options ): array {
261285
'_virtual' => $is_virtual(),
262286
'_depth' => $processor->get_current_depth(),
263287
'_namespace' => $namespace,
288+
'_matches' => $matches,
264289
);
265290

266291
// Self-contained tags contain their inner contents as modifiable text.
@@ -440,6 +465,7 @@ function get_tree( string $html, array $options ): array {
440465
'doctypeSystemId' => $doctype_system_identifier,
441466

442467
'contextNode' => $context_node,
468+
'warnings' => $warnings,
443469
);
444470
}
445471

html-api-debugger/interactivity.php

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function generate_page( string $html, array $options ): string {
3737
'showInvisible' => false,
3838
'showVirtual' => false,
3939
'contextHTML' => $options['context_html'] ?? '',
40+
'selector' => $options['selector'] ?? '',
4041

4142
'hoverInfo' => 'breadcrumbs',
4243
'hoverBreadcrumbs' => true,
@@ -47,6 +48,7 @@ function generate_page( string $html, array $options ): string {
4748
'htmlApiDoctypeName' => $htmlapi_response['result']['doctypeName'] ?? null,
4849
'htmlApiDoctypePublicId' => $htmlapi_response['result']['doctypePublicId'] ?? null,
4950
'htmlApiDoctypeSytemId' => $htmlapi_response['result']['doctypeSystemId'] ?? null,
51+
'treeWarnings' => $htmlapi_response['result']['warnings'] ?? array(),
5052
'normalizedHtml' => $htmlapi_response['normalizedHtml'] ?? '',
5153

5254
'playbackLength' => isset( $htmlapi_response['result']['playback'] )
@@ -63,6 +65,17 @@ function generate_page( string $html, array $options ): string {
6365
data-wp-init="run"
6466
class="html-api-debugger-container html-api-debugger--grid"
6567
>
68+
<div data-wp-bind--hidden="!state.htmlapiResponse.supports.create_fragment_advanced" class="full-width">
69+
<label>Context in which input HTML finds itself
70+
<textarea
71+
class="context-html"
72+
placeholder="Provide a fragment context, for example:&#x0A;<!DOCTYPE html><body>"
73+
title="Leave blank to parse a full document."
74+
rows="2"
75+
data-wp-on-async--input="handleContextHtmlInput"
76+
><?php echo "\n" . esc_textarea( str_replace( "\0", '', $options['context_html'] ?? '' ) ); ?></textarea>
77+
</label>
78+
</div>
6679
<div>
6780
<h2>Input HTML</h2>
6881
<textarea
@@ -139,15 +152,8 @@ class="html-api-debugger-container html-api-debugger--grid"
139152
<label>Show closers <input type="checkbox" data-wp-bind--checked="state.showClosers" data-wp-on-async--input="handleShowClosersClick"></label>
140153
<label>Show invisible <input type="checkbox" data-wp-bind--checked="state.showInvisible" data-wp-on-async--input="handleShowInvisibleClick"></label>
141154
<span><label>Show virtual <input type="checkbox" data-wp-bind--checked="state.showVirtual" data-wp-on-async--input="handleShowVirtualClick"></label></span>
142-
<div data-wp-bind--hidden="!state.htmlapiResponse.supports.create_fragment_advanced">
143-
<label>Context html
144-
<textarea
145-
class="context-html"
146-
placeholder="Provide a fragment context, for example:&#x0A;<!DOCTYPE html><body>"
147-
rows="2"
148-
data-wp-on-async--input="handleContextHtmlInput"
149-
><?php echo "\n" . esc_textarea( str_replace( "\0", '', $options['context_html'] ?? '' ) ); ?></textarea>
150-
</label>
155+
<div data-wp-bind--hidden="!state.htmlapiResponse.supports.selectors">
156+
<label>CSS Selectors <textarea placeholder="CSS selector: .my-class" data-wp-on-async--input="handleSelectorChange"><?php echo "\n" . esc_textarea( str_replace( "\0", '', $options['selector'] ?? '' ) ); ?></textarea></label>
151157
</div>
152158
</div>
153159
<div>
@@ -161,6 +167,13 @@ class="context-html"
161167
</div>
162168
</div>
163169

170+
<div data-wp-bind--hidden="!state.treeWarnings.length">
171+
<template data-wp-each="state.treeWarnings">
172+
<p data-wp-text="context.item" class="error-holder"></p>
173+
</template>
174+
</div>
175+
<p data-wp-bind--hidden="!state.selectorErrorMessage" data-wp-text="state.selectorErrorMessage" class="error-holder"></p>
176+
164177
<div>
165178
<h2>Processed HTML</h2>
166179
<div data-wp-bind--hidden="!state.htmlapiResponse.result.playback">

html-api-debugger/print-html-tree.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { replaceInvisible } from '@html-api-debugger/replace-invisible-chars';
55
* @property {boolean} [showClosers]
66
* @property {boolean} [showInvisible]
77
* @property {boolean} [showVirtual]
8+
* @property {string|null} [selector]
89
* @property {'breadcrumbs'|'insertionMode'} [hoverInfo]
910
*/
1011

@@ -21,6 +22,14 @@ export function printHtmlApiTree(node, ul, options = {}) {
2122
for (let i = 0; i < node.childNodes.length; i += 1) {
2223
const li = document.createElement('li');
2324
li.className = `t${node.childNodes[i].nodeType}`;
25+
26+
if (
27+
node.childNodes[i]._matches ||
28+
(options.selector && node.childNodes[i].matches?.(options.selector))
29+
) {
30+
li.classList.add('matches-selector');
31+
}
32+
2433
if (node.childNodes[i].nodeType === Node.prototype.DOCUMENT_TYPE_NODE) {
2534
li.appendChild(document.createTextNode('DOCTYPE: '));
2635
}

html-api-debugger/style.css

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
}
88

99
.html-api-debugger-container {
10+
--monospace-font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo,
11+
Consolas, Liberation Mono, monospace;
12+
1013
width: 100%;
1114
padding: 20px 20px 0 0;
1215

@@ -32,6 +35,15 @@
3235
grid-column: 1 / -1;
3336
}
3437

38+
.matches-selector {
39+
outline: 1px dotted hotpink;
40+
}
41+
42+
code,
43+
pre {
44+
font-family: var(--monospace-font-family);
45+
}
46+
3547
pre {
3648
background-color: #fff;
3749
border: inset 1px;
@@ -49,12 +61,12 @@
4961
#input_html {
5062
width: 100%;
5163
min-height: 200px;
52-
font-family: monospace;
64+
font-family: var(--monospace-font-family);
5365
}
5466

5567
.context-html {
5668
width: 100%;
57-
font-family: monospace;
69+
font-family: var(--monospace-font-family);
5870

5971
&:placeholder-shown {
6072
font-style: italic;
@@ -142,7 +154,7 @@
142154
border: inset 1px;
143155
padding: 0.5em 0.5em 0.5em 1em;
144156
color: black;
145-
font-family: monospace;
157+
font-family: var(--monospace-font-family);
146158
background: white;
147159
margin: 0;
148160

@@ -165,7 +177,7 @@
165177

166178
.t2 {
167179
font-style: normal;
168-
font-family: monospace;
180+
font-family: var(--monospace-font-family);
169181
}
170182

171183
.t2 .name {

html-api-debugger/view.js

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ let mutationObserver = null;
4949
*
5050
* @typedef Supports
5151
* @property {boolean} create_fragment_advanced
52+
* @property {boolean} selectors
5253
*
5354
*
5455
* @typedef HtmlApiResponse
@@ -67,6 +68,9 @@ let mutationObserver = null;
6768
*
6869
*
6970
* @typedef State
71+
* @property {ReadonlyArray<string>} treeWarnings
72+
* @property {string|null} selector
73+
* @property {string|null} selectorErrorMessage
7074
* @property {boolean} showClosers
7175
* @property {boolean} showInvisible
7276
* @property {boolean} showVirtual
@@ -139,9 +143,16 @@ const store = createStore(NS, {
139143
showInvisible: store.state.showInvisible,
140144
showVirtual: store.state.showVirtual,
141145
hoverInfo: store.state.hoverInfo,
146+
selector: store.state.htmlapiResponse.supports.selectors
147+
? store.state.selector
148+
: '',
142149
};
143150
},
144151

152+
get treeWarnings() {
153+
return store.state.htmlapiResponse.result?.warnings ?? [];
154+
},
155+
145156
get playbackTree() {
146157
if (store.state.playbackPoint === null) {
147158
return undefined;
@@ -253,6 +264,9 @@ const store = createStore(NS, {
253264
if (store.state.contextHTMLForUse) {
254265
searchParams.set('contextHTML', store.state.contextHTMLForUse);
255266
}
267+
if (store.state.selector) {
268+
searchParams.set('selector', store.state.selector);
269+
}
256270
const base = '/wp-admin/admin.php';
257271
const u = new URL(
258272
'https://playground.wordpress.net/?plugin=html-api-debugger',
@@ -396,10 +410,23 @@ const store = createStore(NS, {
396410
/** @type {Element|null} */
397411
let contextElement = null;
398412
if (store.state.contextHTMLForUse) {
399-
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT);
400-
while (walker.nextNode()) {
401-
// @ts-expect-error It's an Element!
402-
contextElement = walker.currentNode;
413+
// An HTML document will always make HTML > HEAD + BODY.
414+
// But that may not be the intended context.
415+
// Guess the intended context in case the HEAD and BODY elements are empty.
416+
if (doc.body.hasChildNodes() || doc.head.hasChildNodes()) {
417+
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT);
418+
while (walker.nextNode()) {
419+
// @ts-expect-error It's an Element!
420+
contextElement = walker.currentNode;
421+
}
422+
} else {
423+
if (/<body\W/i.test(store.state.contextHTMLForUse)) {
424+
contextElement = doc.body;
425+
} else if (/<head\W/i.test(store.state.contextHTMLForUse)) {
426+
contextElement = doc.head;
427+
} else {
428+
contextElement = doc.documentElement;
429+
}
403430
}
404431
if (contextElement) {
405432
store.state.DOM.contextNode = contextElement.nodeName;
@@ -481,6 +508,7 @@ const store = createStore(NS, {
481508
for (const [param, prop] of /** @type {const} */ ([
482509
['html', 'html'],
483510
['contextHTML', 'contextHTMLForUse'],
511+
['selector', 'selector'],
484512
])) {
485513
if (store.state[prop]) {
486514
u.searchParams.set(param, store.state[prop]);
@@ -506,6 +534,7 @@ const store = createStore(NS, {
506534
body: JSON.stringify({
507535
html: store.state.html,
508536
contextHTML: store.state.contextHTMLForUse,
537+
selector: store.state.selector,
509538
}),
510539
headers: {
511540
'Content-Type': 'application/json',
@@ -688,6 +717,50 @@ const store = createStore(NS, {
688717
const val = /** @type {HTMLInputElement} */ (e.target).valueAsNumber;
689718
store.state.playbackPoint = val - 1;
690719
},
720+
721+
/** @param {InputEvent} e */
722+
handleSelectorChange: function* (e) {
723+
const val = /** @type {HTMLInputElement} */ (e.target).value.trim() || null;
724+
if (val) {
725+
try {
726+
// Test whether the selector is valid before setting it so it isn't applied.
727+
document.createDocumentFragment().querySelector(val);
728+
store.state.selector = val;
729+
store.state.selectorErrorMessage = null;
730+
yield store.callAPI();
731+
return;
732+
} catch (/** @type {unknown} */ e) {
733+
if (e instanceof DOMException && e.name === 'SyntaxError') {
734+
let msg = e.message;
735+
736+
/*
737+
* The error message includes methods about our test.
738+
* Chrome:
739+
* > Failed to execute 'querySelector' on 'DocumentFragment': 'foo >' is not a valid selector.
740+
* Firefox:
741+
* > DocumentFragment.querySelector: 'foo >' is not a valid selector
742+
* Safari:
743+
* > 'foo >' is not a valid selector.
744+
*
745+
* Try to strip the irrelevant parts.
746+
*/
747+
let idx = msg.indexOf(val);
748+
if (idx > 0) {
749+
if (msg[idx - 1] === '"' || msg[idx - 1] === "'") {
750+
idx -= 1;
751+
}
752+
msg = msg.slice(idx);
753+
}
754+
755+
store.state.selectorErrorMessage = msg;
756+
} else {
757+
throw e;
758+
}
759+
}
760+
}
761+
store.state.selector = null;
762+
yield store.callAPI();
763+
},
691764
});
692765

693766
/** @param {keyof State} stateKey */

0 commit comments

Comments
 (0)