Skip to content
This repository was archived by the owner on Jul 28, 2023. It is now read-only.
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
128 changes: 128 additions & 0 deletions phpunit/directives/wp-directive-processor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php
/**
* `WP_Directive_Processor` class test.
*/
require_once __DIR__ . '/../../src/directives/class-wp-directive-processor.php';

/**
* @group html-processor
*
* @coversDefaultClass WP_Directive_Processor
*/
class WP_Directive_Processor_Test extends WP_UnitTestCase {
const HTML = '<div>outside</div><section><div><img>inside</div></section>';

public function test_next_balanced_closer_stays_on_void_tag() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'img' );
$result = $tags->next_balanced_closer();
$this->assertSame( 'IMG', $tags->get_tag() );
$this->assertFalse( $result );
}

public function test_next_balanced_closer_proceeds_to_correct_tag() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'section' );
$tags->next_balanced_closer();
$this->assertSame( 'SECTION', $tags->get_tag() );
$this->assertTrue( $tags->is_tag_closer() );
}

public function test_next_balanced_closer_proceeds_to_correct_tag_for_nested_tag() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'div' );
$tags->next_tag( 'div' );
$tags->next_balanced_closer();
$this->assertSame( 'DIV', $tags->get_tag() );
$this->assertTrue( $tags->is_tag_closer() );
}

public function test_get_inner_html_returns_correct_result() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'section' );
$this->assertSame( '<div><img>inside</div>', $tags->get_inner_html() );
}

public function test_set_inner_html_on_void_element_has_no_effect() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'img' );
$content = $tags->set_inner_html( 'This is the new img content' );
$this->assertFalse( $content );
$this->assertSame( self::HTML, $tags->get_updated_html() );
}

public function test_set_inner_html_sets_content_correctly() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'section' );
$tags->set_inner_html( 'This is the new section content.' );
$this->assertSame( '<div>outside</div><section>This is the new section content.</section>', $tags->get_updated_html() );
}

public function test_set_inner_html_updates_bookmarks_correctly() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'div' );
$tags->set_bookmark( 'start' );
$tags->next_tag( 'img' );
$this->assertSame( 'IMG', $tags->get_tag() );
$tags->set_bookmark( 'after' );
$tags->seek( 'start' );

$tags->set_inner_html( 'This is the new div content.' );
$this->assertSame( '<div>This is the new div content.</div><section><div><img>inside</div></section>', $tags->get_updated_html() );
$tags->seek( 'after' );
$this->assertSame( 'IMG', $tags->get_tag() );
}

public function test_set_inner_html_subsequent_updates_on_the_same_tag_work() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'section' );
$tags->set_inner_html( 'This is the new section content.' );
$tags->set_inner_html( 'This is the even newer section content.' );
$this->assertSame( '<div>outside</div><section>This is the even newer section content.</section>', $tags->get_updated_html() );
}

public function test_set_inner_html_followed_by_set_attribute_works() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'section' );
$tags->set_inner_html( 'This is the new section content.' );
$tags->set_attribute( 'id', 'thesection' );
$this->assertSame( '<div>outside</div><section id="thesection">This is the new section content.</section>', $tags->get_updated_html() );
}

public function test_set_inner_html_preceded_by_set_attribute_works() {
$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'section' );
$tags->set_attribute( 'id', 'thesection' );
$tags->set_inner_html( 'This is the new section content.' );
$this->assertSame( '<div>outside</div><section id="thesection">This is the new section content.</section>', $tags->get_updated_html() );
}

public function test_set_inner_html_invalidates_bookmarks_that_point_to_replaced_content() {
$this->markTestSkipped( "This requires on bookmark invalidation, which is only in GB's WP 6.3 compat layer." );

$tags = new WP_Directive_Processor( self::HTML );

$tags->next_tag( 'section' );
$tags->set_bookmark( 'start' );
$tags->next_tag( 'img' );
$tags->set_bookmark( 'replaced' );
$tags->seek( 'start' );

$tags->set_inner_html( 'This is the new section content.' );
$this->assertSame( '<div>outside</div><section>This is the new section content.</section>', $tags->get_updated_html() );

$this->expectExceptionMessage( 'Invalid bookmark name' );
$successful_seek = $tags->seek( 'replaced' );
$this->assertFalse( $successful_seek );
}
}
155 changes: 155 additions & 0 deletions src/directives/class-wp-directive-processor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

class WP_Directive_Processor extends WP_HTML_Tag_Processor {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#156 also had some more directive-specific logic (such as a next_directive method) that I removed. The reason is that it’ll need some more work where we’d need to move some of the logic that’s currently in wp_process_directives into this class. This isn’t currently a priority, so I’ve opted to defer it.

As a consequence however, WP_Directive_Processor doesn’t have any directive-specific code at all — it’s really more of a WP_HTML_Tag_Processor-derived class that allows finding a balanced closing tag, and getting/setting inner HTML. It might thus be worth considering renaming that class.

/**
* Find the matching closing tag for an opening tag.
*
* When called while on an open tag, traverse the HTML until we find
* the matching closing tag, respecting any in-between content, including
* nested tags of the same name. Return false when called on a closing or
* void tag, or if no matching closing tag was found.
*
* @return bool True if a matching closing tag was found.
*/
public function next_balanced_closer() {
$depth = 0;

$tag_name = $this->get_tag();

if ( self::is_html_void_element( $tag_name ) ) {
return false;
}

while ( $this->next_tag(
array(
'tag_name' => $tag_name,
'tag_closers' => 'visit',
)
) ) {
if ( ! $this->is_tag_closer() ) {
$depth++;
continue;
}

if ( 0 === $depth ) {
return true;
}

$depth--;
Comment thread
ockham marked this conversation as resolved.
}

return false;
}

/**
* Return the content between two balanced tags.
*
* When called on an opening tag, return the HTML content found between
* that opening tag and its matching closing tag.
*
* @return string The content between the current opening and its matching closing tag.
*/
public function get_inner_html() {
$bookmarks = $this->get_balanced_tag_bookmarks();
if ( ! $bookmarks ) {
return false;
}
list( $start_name, $end_name ) = $bookmarks;

$start = $this->bookmarks[ $start_name ]->end + 1;
$end = $this->bookmarks[ $end_name ]->start;

$this->seek( $start_name ); // Return to original position.
$this->release_bookmark( $start_name );
$this->release_bookmark( $end_name );

return substr( $this->html, $start, $end - $start );
}

/**
* Set the content between two balanced tags.
*
* When called on an opening tag, set the HTML content found between
* that opening tag and its matching closing tag.
*
* @param string $new_html The string to replace the content between the matching tags with.
* @return bool Whether the content was successfully replaced.
*/
public function set_inner_html( $new_html ) {
$this->get_updated_html(); // Apply potential previous updates.

$bookmarks = $this->get_balanced_tag_bookmarks();
if ( ! $bookmarks ) {
return false;
}
list( $start_name, $end_name ) = $bookmarks;

$start = $this->bookmarks[ $start_name ]->end + 1;
$end = $this->bookmarks[ $end_name ]->start;

$this->seek( $start_name ); // Return to original position.
$this->release_bookmark( $start_name );
$this->release_bookmark( $end_name );

$this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html );
return true;
}

/**
* Return a pair of bookmarks for the current opening tag and the matching closing tag.
*
* @return array|false A pair of bookmarks, or false if there's no matching closing tag.
*/
public function get_balanced_tag_bookmarks() {
$i = 0;
while ( array_key_exists( 'start' . $i, $this->bookmarks ) ) {
++$i;
}
$start_name = 'start' . $i;

$this->set_bookmark( $start_name );
if ( ! $this->next_balanced_closer() ) {
$this->release_bookmark( $start_name );
return false;
}

$i = 0;
while ( array_key_exists( 'end' . $i, $this->bookmarks ) ) {
++$i;
}
$end_name = 'end' . $i;
$this->set_bookmark( $end_name );

return array( $start_name, $end_name );
}

/**
* Whether a given HTML element is void (e.g. <br>).
*
* @param string $tag_name The element in question.
* @return bool True if the element is void.
*
* @see https://html.spec.whatwg.org/#elements-2
*/
public static function is_html_void_element( $tag_name ) {
switch ( $tag_name ) {
case 'AREA':
case 'BASE':
case 'BR':
case 'COL':
case 'EMBED':
case 'HR':
case 'IMG':
case 'INPUT':
case 'LINK':
case 'META':
case 'SOURCE':
case 'TRACK':
case 'WBR':
return true;

default:
return false;
}
}
}
26 changes: 2 additions & 24 deletions src/directives/wp-process-directives.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

require_once __DIR__ . '/class-wp-directive-context.php';
require_once __DIR__ . '/class-wp-directive-processor.php';

function wp_process_directives( $tags, $prefix, $directives ) {
$context = new WP_Directive_Context;
Expand Down Expand Up @@ -41,7 +42,7 @@ function wp_process_directives( $tags, $prefix, $directives ) {
// directives so we can call its directive processor once we encounter the
// matching closing tag.
if (
! is_html_void_element( $tags->get_tag() ) &&
! WP_Directive_Processor::is_html_void_element( $tags->get_tag() ) &&
( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) )
) {
$tag_stack[] = array( $tag_name, $attributes );
Expand All @@ -56,26 +57,3 @@ function wp_process_directives( $tags, $prefix, $directives ) {
return $tags;
}

// TODO: Move into `WP_HTML_Tag_Processor` (or `WP_HTML_Processor`).
// See e.g. https://github.com/WordPress/gutenberg/pull/47573.
function is_html_void_element( $tag_name ) {
switch ( $tag_name ) {
case 'AREA':
case 'BASE':
case 'BR':
case 'COL':
case 'EMBED':
case 'HR':
case 'IMG':
case 'INPUT':
case 'LINK':
case 'META':
case 'SOURCE':
case 'TRACK':
case 'WBR':
return true;

default:
return false;
}
}