-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Introduces WP_HTML_Walker, a PHP class that can update HTML markup.
Dynamic blocks often need to inject a CSS class name or set <img src /> in the rendered block HTML markup but lack the means to do so. WP_HTML_Walker solves this problem. It scans through an HTML document to find specific tags, then transforms those tags by adding, removing, or updating the values of the HTML attributes within that tag (opener). Importantly, it does not fully parse HTML or _recurse_ into the HTML structure. Instead WP_HTML_Walker scans linearly through a document and only parses the HTML tag openers. Example: ``` $w = new WP_HTML_Walker('<div id="first"><img /></div>'); $w->next_tag('img')->set_attribute('src', '/wp-content/logo.png'); echo $w; // <div id="first"><img src="/wp-content/logo.png" /></div> ``` Co-authored-by: Denis Snell <dennis.snell@automattic.com>
- Loading branch information
Showing
2 changed files
with
1,042 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,287 @@ | ||
<?php | ||
|
||
use PHPUnit\Framework\TestCase; | ||
|
||
require_once './class-wp-html-walker.php'; | ||
|
||
final class WP_HTML_Walker_Tests extends TestCase { | ||
const HTML_SIMPLE = '<div id="first"><span id="second">Text</span></div>'; | ||
const HTML_WITH_CLASSES = '<div class="main with-border" id="first"><span class="not-main bold with-border" id="second">Text</span></div>'; | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_to_string_with_no_updates_returns_the_original_html() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$this->assertSame( self::HTML_SIMPLE, $w . '' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_finding_existing_tag_returns_the_walker_object() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$result = $w->next_tag(); | ||
$this->assertSame( $w, $result, 'Finding an existing tag returns the Walker object' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_finding_non_existing_tag_returns_false() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$result = $w->next_tag( 'p' ); | ||
$this->assertFalse( $result ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_set_new_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$w->next_tag()->set_attribute( 'test-attribute', 'test-value' ); | ||
$this->assertSame( '<div test-attribute="test-value" id="first"><span id="second">Text</span></div>', $w . '' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_set_existing_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$w->next_tag()->set_attribute( 'id', 'new-id' ); | ||
$this->assertSame( '<div id="new-id"><span id="second">Text</span></div>', $w . '' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_remove_existing_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$w->next_tag()->remove_attribute( 'id' ); | ||
$this->assertSame( '<div ><span id="second">Text</span></div>', $w . '' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_remove_non_existing_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$w->next_tag()->remove_attribute( 'no-such-attribute' ); | ||
$this->assertSame( '<div id="first"><span id="second">Text</span></div>', $w . '' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_add_class_when_there_is_no_class_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$w->next_tag()->add_class( 'foo-class' ); | ||
$this->assertSame( '<div class="foo-class" id="first"><span id="second">Text</span></div>', $w . '' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_add_two_classes_when_there_is_no_class_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$w->next_tag()->add_class( 'foo-class' )->add_class( 'bar-class' ); | ||
$this->assertSame( '<div class="foo-class bar-class" id="first"><span id="second">Text</span></div>', $w . '' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_remove_class_when_there_is_no_class_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_SIMPLE ); | ||
$w->next_tag()->remove_class( 'foo-class' ); | ||
$this->assertSame( '<div id="first"><span id="second">Text</span></div>', $w . '' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_add_class_when_there_is_a_class_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->next_tag()->add_class( 'foo-class' )->add_class( 'bar-class' ); | ||
$this->assertSame( | ||
'<div class="main with-border foo-class bar-class" id="first"><span class="not-main bold with-border" id="second">Text</span></div>', | ||
$w . '' | ||
); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_remove_class_when_there_is_a_class_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->next_tag()->add_class( 'foo-class' )->add_class( 'bar-class' ); | ||
$this->assertSame( | ||
'<div class="main with-border foo-class bar-class" id="first"><span class="not-main bold with-border" id="second">Text</span></div>', | ||
$w . '' | ||
); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_removing_all_classes_removes_the_class_attribute() { | ||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->next_tag()->remove_class( 'main' )->remove_class( 'with-border' ); | ||
$this->assertSame( | ||
'<div id="first"><span class="not-main bold with-border" id="second">Text</span></div>', | ||
$w . '' | ||
); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_does_not_add_duplicate_class_names() { | ||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->next_tag()->add_class( 'with-border' ); | ||
$this->assertSame( | ||
'<div class="main with-border" id="first"><span class="not-main bold with-border" id="second">Text</span></div>', | ||
$w . '' | ||
); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_preserves_class_name_order_when_a_duplicate_class_name_is_added() { | ||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->next_tag()->add_class( 'main' ); | ||
$this->assertSame( | ||
'<div class="main with-border" id="first"><span class="not-main bold with-border" id="second">Text</span></div>', | ||
$w . '' | ||
); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_set_attribute_takes_priority_over_add_class() { | ||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->next_tag()->add_class( 'add_class' )->set_attribute( 'class', 'set_attribute' ); | ||
$this->assertSame( | ||
'<div class="set_attribute" id="first"><span class="not-main bold with-border" id="second">Text</span></div>', | ||
$w . '' | ||
); | ||
|
||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->next_tag()->set_attribute( 'class', 'set_attribute' )->add_class( 'add_class' ); | ||
$this->assertSame( | ||
'<div class="set_attribute" id="first"><span class="not-main bold with-border" id="second">Text</span></div>', | ||
$w . '' | ||
); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_throws_an_exception_when_updating_an_attribute_without_matching_a_tag() { | ||
$this->expectException( WP_HTML_Walker_Exception::class ); | ||
$this->expectExceptionMessage( 'Cannot update a tag: No tag was matched' ); | ||
|
||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->set_attribute( 'id', 'first' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_throws_an_exception_when_updating_a_closed_walker() { | ||
$this->expectException( WP_HTML_Walker_Exception::class ); | ||
$this->expectExceptionMessage( 'Cannot update a tag: WP_HTML_Walker can only move forward through the HTML document and it has already reached an end.' ); | ||
|
||
$w = new WP_HTML_Walker( self::HTML_WITH_CLASSES ); | ||
$w->next_tag(); | ||
$w->__toString(); // Force the walker to get to the end of the document. | ||
|
||
$w->set_attribute( 'id', 'first' ); | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
public function test_advanced_use_case() { | ||
$input = <<<HTML | ||
<div selected class="merge-message" checked> | ||
<div class="select-menu d-inline-block"> | ||
<div checked class="BtnGroup MixedCaseHTML position-relative" /> | ||
<div checked class="BtnGroup MixedCaseHTML position-relative"> | ||
<button type="button" class="merge-box-button btn-group-merge rounded-left-2 btn BtnGroup-item js-details-target hx_create-pr-button" aria-expanded="false" data-details-container=".js-merge-pr" disabled=""> | ||
Merge pull request | ||
</button> | ||
<button type="button" class="merge-box-button btn-group-squash rounded-left-2 btn BtnGroup-item js-details-target hx_create-pr-button" aria-expanded="false" data-details-container=".js-merge-pr" disabled=""> | ||
Squash and merge | ||
</button> | ||
<button type="button" class="merge-box-button btn-group-rebase rounded-left-2 btn BtnGroup-item js-details-target hx_create-pr-button" aria-expanded="false" data-details-container=".js-merge-pr" disabled=""> | ||
Rebase and merge | ||
</button> | ||
<button aria-label="Select merge method" disabled="disabled" type="button" data-view-component="true" class="select-menu-button btn BtnGroup-item"></button> | ||
</div> | ||
</div> | ||
</div> | ||
HTML; | ||
|
||
$expected_output = <<<HTML | ||
<div data-details="{ "key": "value" }" selected class="merge-message is-processed" checked> | ||
<div class="select-menu d-inline-block"> | ||
<div checked class="MixedCaseHTML position-relative button-group Another-Mixed-Case" /> | ||
<div checked class="MixedCaseHTML position-relative button-group Another-Mixed-Case"> | ||
<button type="button" class="merge-box-button btn-group-merge rounded-left-2 btn BtnGroup-item js-details-target hx_create-pr-button" aria-expanded="false" data-details-container=".js-merge-pr" disabled=""> | ||
Merge pull request | ||
</button> | ||
<button type="button" class="merge-box-button btn-group-squash rounded-left-2 btn BtnGroup-item js-details-target hx_create-pr-button" aria-expanded="false" data-details-container=".js-merge-pr" disabled=""> | ||
Squash and merge | ||
</button> | ||
<button type="button" aria-expanded="false" data-details-container=".js-merge-pr" disabled=""> | ||
Rebase and merge | ||
</button> | ||
<button aria-label="Select merge method" disabled="disabled" type="button" data-view-component="true" class="select-menu-button btn BtnGroup-item"></button> | ||
</div> | ||
</div> | ||
</div> | ||
HTML; | ||
|
||
$w = new WP_HTML_Walker( $input ); | ||
$w | ||
->next_tag( 'div' ) | ||
->set_attribute( 'data-details', '{ "key": "value" }' ) | ||
->add_class( 'is-processed' ) | ||
->next_tag( | ||
array( | ||
'tag_name' => 'div', | ||
'class_name' => 'BtnGroup', | ||
) | ||
) | ||
->remove_class( 'BtnGroup' ) | ||
->add_class( 'button-group' ) | ||
->add_class( 'Another-Mixed-Case' ) | ||
->next_tag( | ||
array( | ||
'tag_name' => 'div', | ||
'class_name' => 'BtnGroup', | ||
) | ||
) | ||
->remove_class( 'BtnGroup' ) | ||
->add_class( 'button-group' ) | ||
->add_class( 'Another-Mixed-Case' ) | ||
->next_tag( | ||
array( | ||
'tag_name' => 'button', | ||
'class_name' => 'btn', | ||
'match_offset' => 2, | ||
) | ||
) | ||
->remove_attribute( 'class' ); | ||
$this->assertSame( $expected_output, $w . '' ); | ||
} | ||
} |
Oops, something went wrong.