Skip to content

Commit

Permalink
Add CSS selector support (#5)
Browse files Browse the repository at this point in the history
Experimental support for selectors in WordPress/wordpress-develop#7857
  • Loading branch information
sirreal authored Dec 10, 2024
1 parent 2769d75 commit fc62b3f
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 17 deletions.
5 changes: 5 additions & 0 deletions html-api-debugger/html-api-debugger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
},
Expand Down Expand Up @@ -112,6 +113,7 @@ function () {

$options = array(
'context_html' => null,
'selector' => null,
);

$html = '';
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions html-api-debugger/html-api-integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
);
}

Expand Down Expand Up @@ -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 );

Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -440,6 +465,7 @@ function get_tree( string $html, array $options ): array {
'doctypeSystemId' => $doctype_system_identifier,

'contextNode' => $context_node,
'warnings' => $warnings,
);
}

Expand Down
31 changes: 22 additions & 9 deletions html-api-debugger/interactivity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'] )
Expand All @@ -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"
>
<div data-wp-bind--hidden="!state.htmlapiResponse.supports.create_fragment_advanced" class="full-width">
<label>Context in which input HTML finds itself
<textarea
class="context-html"
placeholder="Provide a fragment context, for example:&#x0A;<!DOCTYPE html><body>"
title="Leave blank to parse a full document."
rows="2"
data-wp-on-async--input="handleContextHtmlInput"
><?php echo "\n" . esc_textarea( str_replace( "\0", '', $options['context_html'] ?? '' ) ); ?></textarea>
</label>
</div>
<div>
<h2>Input HTML</h2>
<textarea
Expand Down Expand Up @@ -139,15 +152,8 @@ class="html-api-debugger-container html-api-debugger--grid"
<label>Show closers <input type="checkbox" data-wp-bind--checked="state.showClosers" data-wp-on-async--input="handleShowClosersClick"></label>
<label>Show invisible <input type="checkbox" data-wp-bind--checked="state.showInvisible" data-wp-on-async--input="handleShowInvisibleClick"></label>
<span><label>Show virtual <input type="checkbox" data-wp-bind--checked="state.showVirtual" data-wp-on-async--input="handleShowVirtualClick"></label></span>
<div data-wp-bind--hidden="!state.htmlapiResponse.supports.create_fragment_advanced">
<label>Context html
<textarea
class="context-html"
placeholder="Provide a fragment context, for example:&#x0A;<!DOCTYPE html><body>"
rows="2"
data-wp-on-async--input="handleContextHtmlInput"
><?php echo "\n" . esc_textarea( str_replace( "\0", '', $options['context_html'] ?? '' ) ); ?></textarea>
</label>
<div data-wp-bind--hidden="!state.htmlapiResponse.supports.selectors">
<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>
</div>
</div>
<div>
Expand All @@ -161,6 +167,13 @@ class="context-html"
</div>
</div>

<div data-wp-bind--hidden="!state.treeWarnings.length">
<template data-wp-each="state.treeWarnings">
<p data-wp-text="context.item" class="error-holder"></p>
</template>
</div>
<p data-wp-bind--hidden="!state.selectorErrorMessage" data-wp-text="state.selectorErrorMessage" class="error-holder"></p>

<div>
<h2>Processed HTML</h2>
<div data-wp-bind--hidden="!state.htmlapiResponse.result.playback">
Expand Down
9 changes: 9 additions & 0 deletions html-api-debugger/print-html-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
*/

Expand All @@ -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: '));
}
Expand Down
20 changes: 16 additions & 4 deletions html-api-debugger/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -165,7 +177,7 @@

.t2 {
font-style: normal;
font-family: monospace;
font-family: var(--monospace-font-family);
}

.t2 .name {
Expand Down
81 changes: 77 additions & 4 deletions html-api-debugger/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ let mutationObserver = null;
*
* @typedef Supports
* @property {boolean} create_fragment_advanced
* @property {boolean} selectors
*
*
* @typedef HtmlApiResponse
Expand All @@ -67,6 +68,9 @@ let mutationObserver = null;
*
*
* @typedef State
* @property {ReadonlyArray<string>} treeWarnings
* @property {string|null} selector
* @property {string|null} selectorErrorMessage
* @property {boolean} showClosers
* @property {boolean} showInvisible
* @property {boolean} showVirtual
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 (/<body\W/i.test(store.state.contextHTMLForUse)) {
contextElement = doc.body;
} else if (/<head\W/i.test(store.state.contextHTMLForUse)) {
contextElement = doc.head;
} else {
contextElement = doc.documentElement;
}
}
if (contextElement) {
store.state.DOM.contextNode = contextElement.nodeName;
Expand Down Expand Up @@ -481,6 +508,7 @@ const store = createStore(NS, {
for (const [param, prop] of /** @type {const} */ ([
['html', 'html'],
['contextHTML', 'contextHTMLForUse'],
['selector', 'selector'],
])) {
if (store.state[prop]) {
u.searchParams.set(param, store.state[prop]);
Expand All @@ -506,6 +534,7 @@ const store = createStore(NS, {
body: JSON.stringify({
html: store.state.html,
contextHTML: store.state.contextHTMLForUse,
selector: store.state.selector,
}),
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -688,6 +717,50 @@ const store = createStore(NS, {
const val = /** @type {HTMLInputElement} */ (e.target).valueAsNumber;
store.state.playbackPoint = val - 1;
},

/** @param {InputEvent} e */
handleSelectorChange: function* (e) {
const val = /** @type {HTMLInputElement} */ (e.target).value.trim() || null;
if (val) {
try {
// Test whether the selector is valid before setting it so it isn't applied.
document.createDocumentFragment().querySelector(val);
store.state.selector = val;
store.state.selectorErrorMessage = null;
yield store.callAPI();
return;
} catch (/** @type {unknown} */ e) {
if (e instanceof DOMException && e.name === 'SyntaxError') {
let msg = e.message;

/*
* The error message includes methods about our test.
* Chrome:
* > 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 */
Expand Down

0 comments on commit fc62b3f

Please sign in to comment.