Skip to content
This repository has been archived by the owner on Jul 28, 2023. It is now read-only.

WIP: Introduce directive processor #158

Closed
wants to merge 6 commits into from
Closed
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
117 changes: 117 additions & 0 deletions phpunit/directives/wp-directive-processor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?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_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() {
$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 );
}
}
137 changes: 137 additions & 0 deletions src/directives/class-wp-directive-processor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

class WP_Directive_Processor extends WP_HTML_Tag_Processor {
const DIRECTIVE_PREFIX = 'WPX-';

public $html;

public $might_have_directives = true;

public function __construct( $html ) {
parent::__construct( $html );

if ( false === stripos( self::DIRECTIVE_PREFIX, $html ) ) {
$this->might_have_directives = false;
}
}

public function next_directive() {
if ( false === $this->might_have_directives ) {
return false;
}

while ( $this->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
$tag_name = $this->get_tag();
if ( 0 === stripos( self::DIRECTIVE_PREFIX, $tag_name ) ) {
return true;
}

$attribute_directives = $this->get_attribute_names_with_prefix( self::DIRECTIVE_PREFIX );
if ( 0 < count( $attribute_directives ) ) {
return true;
}
}

return false;
}

public function next_balanced_closer() {
$depth = 0;

$tag_name = $this->get_tag();
while ( $this->next_tag( array( 'tag_name' => $tag_name, 'tag_closers' => 'visit' ) ) ) {
if ( ! $this->is_tag_closer() ) {
Copy link

Choose a reason for hiding this comment

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

This needs to check for void tags

Copy link
Member Author

Choose a reason for hiding this comment

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

thank you. this was not intended to be a correct working implementation so there are definitely more problems with it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Carrying over my comment from #169

Note that since we're only visiting tags with the same name as the tag we're starting from, I think this case needs to be handled by bailing before even starting that loop. I.e. if we start at a <br>, the concept of a "matching closing tag" doesn't make sense, so we bail right away. If OTOH we start at a <div>, then we'll be only visiting other <div>s (and </div>s), so we don't need to check if we're encountering a void element.

$depth++;
continue;
}

if ( 0 === $depth ) {
Copy link

Choose a reason for hiding this comment

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

Return false or throw if the tag name doesn't match?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Note that we're currently limiting the loop to only visit tags whose name does match.

return true;
}

$depth--;
}

return false;
}

public function get_inner_html() {
Copy link

Choose a reason for hiding this comment

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

set_outer_html will be challenging as we'll have to seek to an index that we'll also delete. In the HTML processor I ended up inventing "pinned" bookmarks that don't get updated or released when the lexical updates are applied.

Copy link

Choose a reason for hiding this comment

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

Actually scratch that, I tried seek()ing back to the original tag. That's not what you do here – I like it. Moving to the next tag makes things easier – I took a stab in 51bf340.

$this->set_bookmark( 'start' );
Copy link

Choose a reason for hiding this comment

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

set_bookmark may fail and that name may already be taken. I had the same issue in the HTML Processor. We need some way to set internal bookmarks in a different namespace.

Copy link
Member Author

Choose a reason for hiding this comment

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

correct, though this was not intended to be a correct working implementation. I wanted to keep things terse to explore the idea and not distract the main functionality with all the details

if ( ! $this->next_balanced_closer() ) {
$this->release_bookmark( 'start' );
Copy link

Choose a reason for hiding this comment

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

How about wrapping this entire function in try { } finally { $this->release_bookmark('start'); } ?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not very keen on using error-handling constructs like this as code conveniences. there's an obvious question it raises, "why is the try there?"

Copy link

Choose a reason for hiding this comment

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

because finally { } wouldn't work on its own 🤣

return false;
}
$this->set_bookmark( 'end' );

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

$this->release_bookmark( 'start' );
$this->release_bookmark( 'end' );

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

public function set_inner_html( $new_html ) {
$this->set_bookmark( 'start' );
if ( ! $this->next_balanced_closer() ) {
$this->release_bookmark( 'start' );
return false;
}
$this->set_bookmark( 'end' );

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

$this->release_bookmark( 'start' );
$this->release_bookmark( 'end' );

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

public function get_outer_html( $new_html ) {
$this->set_bookmark( 'start' );
if ( ! $this->next_balanced_closer() ) {
$this->release_bookmark( 'start' );
return false;
}
$this->set_bookmark( 'end' );

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

$this->release_bookmark( 'start' );
$this->release_bookmark( 'end' );

// For consistency with set_outer_html:
$this->next_tag();
return substr( $this->html, $start, $end - $start );
}

public function set_outer_html( $new_html ) {
$this->set_bookmark( 'start' );
if ( ! $this->next_balanced_closer() ) {
$this->release_bookmark( 'start' );
return false;
}
$this->set_bookmark( 'end' );

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

$this->release_bookmark( 'start' );
$this->release_bookmark( 'end' );

$this->next_tag();
$this->set_bookmark( 'next' );
$this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html );
// updates before the current position are not supported well and we end
// up at an invalid combination of copied bytes and parsed bytes index.
// bookmarks are updated correctly, though, so seek() makes it right again.
$this->seek('next');
$this->release_bookmark( 'next' );
return true;
}

}