Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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