From fc62b3f6ecd10634d61dae715735a30148ccf6c2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 10 Dec 2024 18:19:25 +0100 Subject: [PATCH] Add CSS selector support (#5) Experimental support for selectors in https://github.com/WordPress/wordpress-develop/pull/7857 --- html-api-debugger/html-api-debugger.php | 5 ++ html-api-debugger/html-api-integration.php | 26 +++++++ html-api-debugger/interactivity.php | 31 ++++++--- html-api-debugger/print-html-tree.js | 9 +++ html-api-debugger/style.css | 20 ++++-- html-api-debugger/view.js | 81 ++++++++++++++++++++-- 6 files changed, 155 insertions(+), 17 deletions(-) diff --git a/html-api-debugger/html-api-debugger.php b/html-api-debugger/html-api-debugger.php index 6a4a3f0..6aaefe3 100644 --- a/html-api-debugger/html-api-debugger.php +++ b/html-api-debugger/html-api-debugger.php @@ -45,6 +45,7 @@ function () { $html = $request->get_json_params()['html'] ?: ''; $options = array( 'context_html' => $request->get_json_params()['contextHTML'] ?: null, + 'selector' => $request->get_json_params()['selector'] ?: null, ); return prepare_html_result_object( $html, $options ); }, @@ -112,6 +113,7 @@ function () { $options = array( 'context_html' => null, + 'selector' => null, ); $html = ''; @@ -122,6 +124,9 @@ function () { if ( isset( $_GET['contextHTML'] ) && is_string( $_GET['contextHTML'] ) ) { $options['context_html'] = stripslashes( $_GET['contextHTML'] ); } + if ( isset( $_GET['selector'] ) && is_string( $_GET['selector'] ) ) { + $options['selector'] = stripslashes( $_GET['selector'] ); + } // phpcs:enable WordPress.Security.NonceVerification.Recommended // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped diff --git a/html-api-debugger/html-api-integration.php b/html-api-debugger/html-api-integration.php index 0875ecb..c9a4410 100644 --- a/html-api-debugger/html-api-integration.php +++ b/html-api-debugger/html-api-integration.php @@ -12,6 +12,9 @@ function get_supports(): array { return array( 'create_fragment_advanced' => method_exists( WP_HTML_Processor::class, 'create_fragment_at_current_node' ), + 'selectors' => + class_exists( '\WP_CSS_Complex_Selector_List' ) + || class_exists( '\WP_CSS_Compound_Selector_List' ), ); } @@ -70,6 +73,25 @@ function get_normalized_html( string $html, array $options ): ?string { * @param array $options The options. */ function get_tree( string $html, array $options ): array { + /** + * Messages generated during parse. + * + * @var string[] + */ + $warnings = array(); + $selector = null; + if ( isset( $options['selector'] ) && class_exists( '\WP_CSS_Complex_Selector_List' ) ) { + $selector = \WP_CSS_Complex_Selector_List::from_selectors( $options['selector'] ); + if ( null === $selector ) { + $warnings[] = 'The provided selector is invalid or unsupported.'; + } + } elseif ( isset( $options['selector'] ) && class_exists( '\WP_CSS_Compound_Selector_List' ) ) { + $selector = \WP_CSS_Compound_Selector_List::from_selectors( $options['selector'] ); + if ( null === $selector ) { + $warnings[] = 'The provided selector is invalid or unsupported.'; + } + } + $processor_state = new ReflectionProperty( WP_HTML_Processor::class, 'state' ); $processor_state->setAccessible( true ); @@ -225,6 +247,8 @@ function get_tree( string $html, array $options ): array { $document_title = $processor->get_modifiable_text(); } + $matches = $selector !== null && $selector->matches( $processor ); + $attributes = array(); $attribute_names = $processor->get_attribute_names_with_prefix( '' ); if ( null !== $attribute_names ) { @@ -261,6 +285,7 @@ function get_tree( string $html, array $options ): array { '_virtual' => $is_virtual(), '_depth' => $processor->get_current_depth(), '_namespace' => $namespace, + '_matches' => $matches, ); // Self-contained tags contain their inner contents as modifiable text. @@ -440,6 +465,7 @@ function get_tree( string $html, array $options ): array { 'doctypeSystemId' => $doctype_system_identifier, 'contextNode' => $context_node, + 'warnings' => $warnings, ); } diff --git a/html-api-debugger/interactivity.php b/html-api-debugger/interactivity.php index 4fb7e43..ae03226 100644 --- a/html-api-debugger/interactivity.php +++ b/html-api-debugger/interactivity.php @@ -37,6 +37,7 @@ function generate_page( string $html, array $options ): string { 'showInvisible' => false, 'showVirtual' => false, 'contextHTML' => $options['context_html'] ?? '', + 'selector' => $options['selector'] ?? '', 'hoverInfo' => 'breadcrumbs', 'hoverBreadcrumbs' => true, @@ -47,6 +48,7 @@ function generate_page( string $html, array $options ): string { 'htmlApiDoctypeName' => $htmlapi_response['result']['doctypeName'] ?? null, 'htmlApiDoctypePublicId' => $htmlapi_response['result']['doctypePublicId'] ?? null, 'htmlApiDoctypeSytemId' => $htmlapi_response['result']['doctypeSystemId'] ?? null, + 'treeWarnings' => $htmlapi_response['result']['warnings'] ?? array(), 'normalizedHtml' => $htmlapi_response['normalizedHtml'] ?? '', 'playbackLength' => isset( $htmlapi_response['result']['playback'] ) @@ -63,6 +65,17 @@ function generate_page( string $html, array $options ): string { data-wp-init="run" class="html-api-debugger-container html-api-debugger--grid" > +
+ +

Input HTML

- +
+
@@ -161,6 +167,13 @@ class="context-html"
+
+ +
+

+

Processed HTML

diff --git a/html-api-debugger/print-html-tree.js b/html-api-debugger/print-html-tree.js index 378150c..2ba33ec 100644 --- a/html-api-debugger/print-html-tree.js +++ b/html-api-debugger/print-html-tree.js @@ -5,6 +5,7 @@ import { replaceInvisible } from '@html-api-debugger/replace-invisible-chars'; * @property {boolean} [showClosers] * @property {boolean} [showInvisible] * @property {boolean} [showVirtual] + * @property {string|null} [selector] * @property {'breadcrumbs'|'insertionMode'} [hoverInfo] */ @@ -21,6 +22,14 @@ export function printHtmlApiTree(node, ul, options = {}) { for (let i = 0; i < node.childNodes.length; i += 1) { const li = document.createElement('li'); li.className = `t${node.childNodes[i].nodeType}`; + + if ( + node.childNodes[i]._matches || + (options.selector && node.childNodes[i].matches?.(options.selector)) + ) { + li.classList.add('matches-selector'); + } + if (node.childNodes[i].nodeType === Node.prototype.DOCUMENT_TYPE_NODE) { li.appendChild(document.createTextNode('DOCTYPE: ')); } diff --git a/html-api-debugger/style.css b/html-api-debugger/style.css index 9094aed..433723e 100644 --- a/html-api-debugger/style.css +++ b/html-api-debugger/style.css @@ -7,6 +7,9 @@ } .html-api-debugger-container { + --monospace-font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, + Consolas, Liberation Mono, monospace; + width: 100%; padding: 20px 20px 0 0; @@ -32,6 +35,15 @@ grid-column: 1 / -1; } + .matches-selector { + outline: 1px dotted hotpink; + } + + code, + pre { + font-family: var(--monospace-font-family); + } + pre { background-color: #fff; border: inset 1px; @@ -49,12 +61,12 @@ #input_html { width: 100%; min-height: 200px; - font-family: monospace; + font-family: var(--monospace-font-family); } .context-html { width: 100%; - font-family: monospace; + font-family: var(--monospace-font-family); &:placeholder-shown { font-style: italic; @@ -142,7 +154,7 @@ border: inset 1px; padding: 0.5em 0.5em 0.5em 1em; color: black; - font-family: monospace; + font-family: var(--monospace-font-family); background: white; margin: 0; @@ -165,7 +177,7 @@ .t2 { font-style: normal; - font-family: monospace; + font-family: var(--monospace-font-family); } .t2 .name { diff --git a/html-api-debugger/view.js b/html-api-debugger/view.js index c3345c0..88c1ff9 100644 --- a/html-api-debugger/view.js +++ b/html-api-debugger/view.js @@ -49,6 +49,7 @@ let mutationObserver = null; * * @typedef Supports * @property {boolean} create_fragment_advanced + * @property {boolean} selectors * * * @typedef HtmlApiResponse @@ -67,6 +68,9 @@ let mutationObserver = null; * * * @typedef State + * @property {ReadonlyArray} treeWarnings + * @property {string|null} selector + * @property {string|null} selectorErrorMessage * @property {boolean} showClosers * @property {boolean} showInvisible * @property {boolean} showVirtual @@ -139,9 +143,16 @@ const store = createStore(NS, { showInvisible: store.state.showInvisible, showVirtual: store.state.showVirtual, hoverInfo: store.state.hoverInfo, + selector: store.state.htmlapiResponse.supports.selectors + ? store.state.selector + : '', }; }, + get treeWarnings() { + return store.state.htmlapiResponse.result?.warnings ?? []; + }, + get playbackTree() { if (store.state.playbackPoint === null) { return undefined; @@ -253,6 +264,9 @@ const store = createStore(NS, { if (store.state.contextHTMLForUse) { searchParams.set('contextHTML', store.state.contextHTMLForUse); } + if (store.state.selector) { + searchParams.set('selector', store.state.selector); + } const base = '/wp-admin/admin.php'; const u = new URL( 'https://playground.wordpress.net/?plugin=html-api-debugger', @@ -396,10 +410,23 @@ const store = createStore(NS, { /** @type {Element|null} */ let contextElement = null; if (store.state.contextHTMLForUse) { - const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT); - while (walker.nextNode()) { - // @ts-expect-error It's an Element! - contextElement = walker.currentNode; + // An HTML document will always make HTML > HEAD + BODY. + // But that may not be the intended context. + // Guess the intended context in case the HEAD and BODY elements are empty. + if (doc.body.hasChildNodes() || doc.head.hasChildNodes()) { + const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT); + while (walker.nextNode()) { + // @ts-expect-error It's an Element! + contextElement = walker.currentNode; + } + } else { + if (/ Failed to execute 'querySelector' on 'DocumentFragment': 'foo >' is not a valid selector. + * Firefox: + * > DocumentFragment.querySelector: 'foo >' is not a valid selector + * Safari: + * > 'foo >' is not a valid selector. + * + * Try to strip the irrelevant parts. + */ + let idx = msg.indexOf(val); + if (idx > 0) { + if (msg[idx - 1] === '"' || msg[idx - 1] === "'") { + idx -= 1; + } + msg = msg.slice(idx); + } + + store.state.selectorErrorMessage = msg; + } else { + throw e; + } + } + } + store.state.selector = null; + yield store.callAPI(); + }, }); /** @param {keyof State} stateKey */