diff --git a/CHANGELOG.md b/CHANGELOG.md index 616e24fa5a..b98143ba23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,14 @@ All notable changes to this project are documented in this file by a CI job that runs on every NPM release. The file follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## [v1.0.7] (2024-10-28) +## [v1.0.7] (2024-10-28) - - - -## [v1.0.6] (2024-10-28) +## [v1.0.6] (2024-10-28) ### Website -- Query API: Preserve multiple ?plugin= query params. ([#1947](https://github.com/WordPress/wordpress-playground/pull/1947)) -- [Remote] Enable releasing @wp-playground/remote by making it public. ([#1948](https://github.com/WordPress/wordpress-playground/pull/1948)) +- Query API: Preserve multiple ?plugin= query params. ([#1947](https://github.com/WordPress/wordpress-playground/pull/1947)) +- [Remote] Enable releasing @wp-playground/remote by making it public. ([#1948](https://github.com/WordPress/wordpress-playground/pull/1948)) ### Contributors @@ -22,39 +19,38 @@ The following contributors merged PRs in this release: @adamziel @bgrgicak - -## [v1.0.5] (2024-10-25) +## [v1.0.5] (2024-10-25) ### Enhancements -- [CORS Proxy] Rate-limits IPv6 requests based on /64 subnets, not specific addresses. ([#1923](https://github.com/WordPress/wordpress-playground/pull/1923)) +- [CORS Proxy] Rate-limits IPv6 requests based on /64 subnets, not specific addresses. ([#1923](https://github.com/WordPress/wordpress-playground/pull/1923)) ### Blueprints -- Reload after autologin to set login cookies during boot. ([#1914](https://github.com/WordPress/wordpress-playground/pull/1914)) -- Skip empty lines in the runSql step. ([#1939](https://github.com/WordPress/wordpress-playground/pull/1939)) +- Reload after autologin to set login cookies during boot. ([#1914](https://github.com/WordPress/wordpress-playground/pull/1914)) +- Skip empty lines in the runSql step. ([#1939](https://github.com/WordPress/wordpress-playground/pull/1939)) ### Documentation -- Clarified wp beta to also include rc version. ([#1936](https://github.com/WordPress/wordpress-playground/pull/1936)) +- Clarified wp beta to also include rc version. ([#1936](https://github.com/WordPress/wordpress-playground/pull/1936)) ### PHP WebAssembly -- Enable CURL in Playground Web. ([#1935](https://github.com/WordPress/wordpress-playground/pull/1935)) -- PHP: Implement TLS 1.2 to decrypt https:// and ssl:// traffic and translate it into fetch(). ([#1926](https://github.com/WordPress/wordpress-playground/pull/1926)) +- Enable CURL in Playground Web. ([#1935](https://github.com/WordPress/wordpress-playground/pull/1935)) +- PHP: Implement TLS 1.2 to decrypt https:// and ssl:// traffic and translate it into fetch(). ([#1926](https://github.com/WordPress/wordpress-playground/pull/1926)) ### Website -- Hide Settings menu after clicking "Restore from .zip. ([#1904](https://github.com/WordPress/wordpress-playground/pull/1904)) -- Publish @wp-playground/remote (types only). ([#1924](https://github.com/WordPress/wordpress-playground/pull/1924)) +- Hide Settings menu after clicking "Restore from .zip. ([#1904](https://github.com/WordPress/wordpress-playground/pull/1904)) +- Publish @wp-playground/remote (types only). ([#1924](https://github.com/WordPress/wordpress-playground/pull/1924)) ### Bug Fixes -- CORS Proxy: Index update_at column because it is used for lookup. ([#1931](https://github.com/WordPress/wordpress-playground/pull/1931)) -- CORS Proxy: Reject targeting self. ([#1932](https://github.com/WordPress/wordpress-playground/pull/1932)) -- Docs: Fix typo. ([#1934](https://github.com/WordPress/wordpress-playground/pull/1934)) -- Explicitly request no-cache to discourage WP Cloud from edge caching CORS proxy results. ([#1930](https://github.com/WordPress/wordpress-playground/pull/1930)) -- Remove test code added in #1914. ([#1928](https://github.com/WordPress/wordpress-playground/pull/1928)) +- CORS Proxy: Index update_at column because it is used for lookup. ([#1931](https://github.com/WordPress/wordpress-playground/pull/1931)) +- CORS Proxy: Reject targeting self. ([#1932](https://github.com/WordPress/wordpress-playground/pull/1932)) +- Docs: Fix typo. ([#1934](https://github.com/WordPress/wordpress-playground/pull/1934)) +- Explicitly request no-cache to discourage WP Cloud from edge caching CORS proxy results. ([#1930](https://github.com/WordPress/wordpress-playground/pull/1930)) +- Remove test code added in #1914. ([#1928](https://github.com/WordPress/wordpress-playground/pull/1928)) ### Contributors @@ -62,7 +58,6 @@ The following contributors merged PRs in this release: @adamziel @ajotka @bgrgicak @bph @brandonpayton @ockham @psrpinto - ## [v1.0.4] (2024-10-21) ### Enhancements diff --git a/packages/docs/site/docs/main/changelog.md b/packages/docs/site/docs/main/changelog.md index c04560c903..5e93eb1bf7 100644 --- a/packages/docs/site/docs/main/changelog.md +++ b/packages/docs/site/docs/main/changelog.md @@ -9,17 +9,14 @@ All notable changes to this project are documented in this file by a CI job that runs on every NPM release. The file follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. -## [v1.0.7] (2024-10-28) +## [v1.0.7] (2024-10-28) - - - -## [v1.0.6] (2024-10-28) +## [v1.0.6] (2024-10-28) ### Website -- Query API: Preserve multiple ?plugin= query params. ([#1947](https://github.com/WordPress/wordpress-playground/pull/1947)) -- [Remote] Enable releasing @wp-playground/remote by making it public. ([#1948](https://github.com/WordPress/wordpress-playground/pull/1948)) +- Query API: Preserve multiple ?plugin= query params. ([#1947](https://github.com/WordPress/wordpress-playground/pull/1947)) +- [Remote] Enable releasing @wp-playground/remote by making it public. ([#1948](https://github.com/WordPress/wordpress-playground/pull/1948)) ### Contributors @@ -27,39 +24,38 @@ The following contributors merged PRs in this release: @adamziel @bgrgicak - -## [v1.0.5] (2024-10-25) +## [v1.0.5] (2024-10-25) ### Enhancements -- [CORS Proxy] Rate-limits IPv6 requests based on /64 subnets, not specific addresses. ([#1923](https://github.com/WordPress/wordpress-playground/pull/1923)) +- [CORS Proxy] Rate-limits IPv6 requests based on /64 subnets, not specific addresses. ([#1923](https://github.com/WordPress/wordpress-playground/pull/1923)) ### Blueprints -- Reload after autologin to set login cookies during boot. ([#1914](https://github.com/WordPress/wordpress-playground/pull/1914)) -- Skip empty lines in the runSql step. ([#1939](https://github.com/WordPress/wordpress-playground/pull/1939)) +- Reload after autologin to set login cookies during boot. ([#1914](https://github.com/WordPress/wordpress-playground/pull/1914)) +- Skip empty lines in the runSql step. ([#1939](https://github.com/WordPress/wordpress-playground/pull/1939)) ### Documentation -- Clarified wp beta to also include rc version. ([#1936](https://github.com/WordPress/wordpress-playground/pull/1936)) +- Clarified wp beta to also include rc version. ([#1936](https://github.com/WordPress/wordpress-playground/pull/1936)) ### PHP WebAssembly -- Enable CURL in Playground Web. ([#1935](https://github.com/WordPress/wordpress-playground/pull/1935)) -- PHP: Implement TLS 1.2 to decrypt https:// and ssl:// traffic and translate it into fetch(). ([#1926](https://github.com/WordPress/wordpress-playground/pull/1926)) +- Enable CURL in Playground Web. ([#1935](https://github.com/WordPress/wordpress-playground/pull/1935)) +- PHP: Implement TLS 1.2 to decrypt https:// and ssl:// traffic and translate it into fetch(). ([#1926](https://github.com/WordPress/wordpress-playground/pull/1926)) ### Website -- Hide Settings menu after clicking "Restore from .zip. ([#1904](https://github.com/WordPress/wordpress-playground/pull/1904)) -- Publish @wp-playground/remote (types only). ([#1924](https://github.com/WordPress/wordpress-playground/pull/1924)) +- Hide Settings menu after clicking "Restore from .zip. ([#1904](https://github.com/WordPress/wordpress-playground/pull/1904)) +- Publish @wp-playground/remote (types only). ([#1924](https://github.com/WordPress/wordpress-playground/pull/1924)) ### Bug Fixes -- CORS Proxy: Index update_at column because it is used for lookup. ([#1931](https://github.com/WordPress/wordpress-playground/pull/1931)) -- CORS Proxy: Reject targeting self. ([#1932](https://github.com/WordPress/wordpress-playground/pull/1932)) -- Docs: Fix typo. ([#1934](https://github.com/WordPress/wordpress-playground/pull/1934)) -- Explicitly request no-cache to discourage WP Cloud from edge caching CORS proxy results. ([#1930](https://github.com/WordPress/wordpress-playground/pull/1930)) -- Remove test code added in #1914. ([#1928](https://github.com/WordPress/wordpress-playground/pull/1928)) +- CORS Proxy: Index update_at column because it is used for lookup. ([#1931](https://github.com/WordPress/wordpress-playground/pull/1931)) +- CORS Proxy: Reject targeting self. ([#1932](https://github.com/WordPress/wordpress-playground/pull/1932)) +- Docs: Fix typo. ([#1934](https://github.com/WordPress/wordpress-playground/pull/1934)) +- Explicitly request no-cache to discourage WP Cloud from edge caching CORS proxy results. ([#1930](https://github.com/WordPress/wordpress-playground/pull/1930)) +- Remove test code added in #1914. ([#1928](https://github.com/WordPress/wordpress-playground/pull/1928)) ### Contributors @@ -67,7 +63,6 @@ The following contributors merged PRs in this release: @adamziel @ajotka @bgrgicak @bph @brandonpayton @ockham @psrpinto - ## [v1.0.4] (2024-10-21) ### Enhancements diff --git a/packages/playground/data-liberation/bootstrap.php b/packages/playground/data-liberation/bootstrap.php index 5db36a8cf8..9ab7a0dd86 100644 --- a/packages/playground/data-liberation/bootstrap.php +++ b/packages/playground/data-liberation/bootstrap.php @@ -1,5 +1,13 @@ + tests/WPWXRURLRewriterTests.php tests/WPRewriteUrlsTests.php tests/WPURLInTextProcessorTests.php tests/WPBlockMarkupProcessorTests.php tests/WPBlockMarkupUrlProcessorTests.php tests/URLParserWHATWGComplianceTests.php - tests/UrldecodeNTests.php + tests/WPXMLProcessorTests.php + tests/WPXMLTagProcessorTests.php + tests/UrldecodeNTests.php diff --git a/packages/playground/data-liberation/src/WP_URL_In_Text_Processor.php b/packages/playground/data-liberation/src/WP_URL_In_Text_Processor.php index 246427eef8..7458d64920 100644 --- a/packages/playground/data-liberation/src/WP_URL_In_Text_Processor.php +++ b/packages/playground/data-liberation/src/WP_URL_In_Text_Processor.php @@ -233,7 +233,7 @@ public function next_url() { } $tld = strtolower( substr( $parsed_url->hostname, $last_dot_position + 1 ) ); - if ( empty( self::$public_suffix_list[ $tld ] ) ) { + if ( empty( self::$public_suffix_list[ $tld ] ) && $tld !== 'internal' ) { // This TLD is not in the public suffix list. It's not a valid domain name. continue; } diff --git a/packages/playground/data-liberation/src/WP_WXR_URL_Rewrite_Processor.php b/packages/playground/data-liberation/src/WP_WXR_URL_Rewrite_Processor.php new file mode 100644 index 0000000000..34caa67513 --- /dev/null +++ b/packages/playground/data-liberation/src/WP_WXR_URL_Rewrite_Processor.php @@ -0,0 +1,47 @@ +get_modifiable_text(); + $updated_text = wp_rewrite_urls( + array( + 'block_markup' => $text, + 'current-site-url' => $current_site_url, + 'new-site-url' => $new_site_url, + ) + ); + if ( $updated_text !== $text ) { + $processor->set_modifiable_text( $updated_text ); + } + } + } + ); + } + + private static function is_wxr_content_node( WP_XML_Processor $processor ) { + $breadcrumbs = $processor->get_breadcrumbs(); + if ( + ! in_array( 'excerpt:encoded', $breadcrumbs, true ) && + ! in_array( 'content:encoded', $breadcrumbs, true ) && + ! in_array( 'guid', $breadcrumbs, true ) && + ! in_array( 'link', $breadcrumbs, true ) && + ! in_array( 'wp:attachment_url', $breadcrumbs, true ) && + ! in_array( 'wp:comment_content', $breadcrumbs, true ) && + ! in_array( 'wp:base_site_url', $breadcrumbs, true ) && + ! in_array( 'wp:base_blog_url', $breadcrumbs, true ) + // Meta values are not supported yet. We'll need to support + // WordPress core options that may be saved as JSON, PHP Deserialization, and XML, + // and then provide extension points for plugins authors support + // their own options. + // !in_array('wp:postmeta', $processor->get_breadcrumbs()) + ) { + return false; + } + return true; + } +} diff --git a/packages/playground/data-liberation/src/stream-api/WP_Byte_Stream.php b/packages/playground/data-liberation/src/stream-api/WP_Byte_Stream.php new file mode 100644 index 0000000000..bd80b402ae --- /dev/null +++ b/packages/playground/data-liberation/src/stream-api/WP_Byte_Stream.php @@ -0,0 +1,80 @@ +state = new WP_Byte_Stream_State(); + } + + public function is_eof(): bool { + return ! $this->state->output_bytes && $this->state->state === WP_Byte_Stream_State::STATE_FINISHED; + } + + public function get_file_id() { + return $this->state->file_id; + } + + public function skip_file(): void { + $this->state->last_skipped_file = $this->state->file_id; + } + + public function is_skipped_file() { + return $this->state->file_id === $this->state->last_skipped_file; + } + + public function get_chunk_type() { + if ( $this->get_last_error() ) { + return '#error'; + } + + if ( $this->is_eof() ) { + return '#eof'; + } + + return '#bytes'; + } + + public function append_eof() { + $this->state->input_eof = true; + } + + public function append_bytes( string $bytes, $context = null ) { + $this->state->input_bytes .= $bytes; + $this->state->input_context = $context; + } + + public function get_bytes() { + return $this->state->output_bytes; + } + + public function next_bytes() { + $this->state->reset_output(); + if ( $this->is_eof() ) { + return false; + } + + // Process any remaining buffered input: + if ( $this->generate_next_chunk() ) { + return ! $this->is_skipped_file(); + } + + if ( ! $this->state->input_bytes ) { + if ( $this->state->input_eof ) { + $this->state->finish(); + } + return false; + } + + $produced_bytes = $this->generate_next_chunk(); + + return $produced_bytes && ! $this->is_skipped_file(); + } + + abstract protected function generate_next_chunk(): bool; + + public function get_last_error(): string|null { + return $this->state->last_error; + } +} diff --git a/packages/playground/data-liberation/src/stream-api/WP_Byte_Stream_State.php b/packages/playground/data-liberation/src/stream-api/WP_Byte_Stream_State.php new file mode 100644 index 0000000000..1c78f6100b --- /dev/null +++ b/packages/playground/data-liberation/src/stream-api/WP_Byte_Stream_State.php @@ -0,0 +1,40 @@ +output_bytes = null; + $this->file_id = 'default'; + $this->last_error = null; + } + + public function consume_input_bytes() { + $bytes = $this->input_bytes; + $this->input_bytes = null; + return $bytes; + } + + public function finish() { + $this->state = self::STATE_FINISHED; + } +} diff --git a/packages/playground/data-liberation/src/stream-api/WP_File_Byte_Stream.php b/packages/playground/data-liberation/src/stream-api/WP_File_Byte_Stream.php new file mode 100644 index 0000000000..284e7c37a5 --- /dev/null +++ b/packages/playground/data-liberation/src/stream-api/WP_File_Byte_Stream.php @@ -0,0 +1,47 @@ +file_path = $file_path; + $this->chunk_size = $chunk_size; + parent::__construct(); + } + + public function pause() { + return array( + 'file_path' => $this->file_path, + 'chunk_size' => $this->chunk_size, + 'offset_in_file' => $this->offset_in_file, + 'output_bytes' => $this->state->output_bytes, + ); + } + + public function resume( $paused_state ) { + $this->offset_in_file = $paused_state['offset_in_file']; + $this->state->output_bytes = $paused_state['output_bytes']; + } + + protected function generate_next_chunk(): bool { + if ( ! $this->file_pointer ) { + $this->file_pointer = fopen( $this->file_path, 'r' ); + if ( $this->offset_in_file ) { + fseek( $this->file_pointer, $this->offset_in_file ); + } + } + $bytes = fread( $this->file_pointer, $this->chunk_size ); + if ( ! $bytes && feof( $this->file_pointer ) ) { + fclose( $this->file_pointer ); + $this->state->finish(); + return false; + } + $this->offset_in_file += strlen( $bytes ); + $this->state->output_bytes .= $bytes; + return true; + } +} diff --git a/packages/playground/data-liberation/src/stream-api/WP_Processor_Byte_Stream.php b/packages/playground/data-liberation/src/stream-api/WP_Processor_Byte_Stream.php new file mode 100644 index 0000000000..567f7286f8 --- /dev/null +++ b/packages/playground/data-liberation/src/stream-api/WP_Processor_Byte_Stream.php @@ -0,0 +1,29 @@ +processor = $processor; + $this->generate_next_chunk_callback = $generate_next_chunk_callback; + parent::__construct( $generate_next_chunk_callback ); + } + + public function pause() { + return array( + 'processor' => $this->processor->pause(), + 'output_bytes' => $this->state->output_bytes, + ); + } + + public function resume( $paused_state ) { + $this->processor->resume( $paused_state['processor'] ); + $this->state->output_bytes = $paused_state['output_bytes']; + } + + protected function generate_next_chunk(): bool { + return ( $this->generate_next_chunk_callback )( $this->state ); + } +} diff --git a/packages/playground/data-liberation/src/stream-api/WP_Stream_Chain.php b/packages/playground/data-liberation/src/stream-api/WP_Stream_Chain.php new file mode 100644 index 0000000000..e128471a75 --- /dev/null +++ b/packages/playground/data-liberation/src/stream-api/WP_Stream_Chain.php @@ -0,0 +1,271 @@ +chunk_context['chain'] = $this; + + $named_streams = array(); + foreach ( $streams as $name => $stream ) { + $string_name = is_numeric( $name ) ? 'stream_' . $name : $name; + $named_streams[ $string_name ] = $streams[ $name ]; + } + + $this->streams = $named_streams; + $this->streams_names = array_keys( $this->streams ); + $this->first_stream = $this->streams[ $this->streams_names[0] ]; + $this->last_stream = $this->streams[ $this->streams_names[ count( $streams ) - 1 ] ]; + parent::__construct(); + } + + public function pause() { + $paused_streams = array(); + foreach ( $this->streams as $name => $stream ) { + $paused_streams[ $name ] = $stream->pause(); + } + $paused_execution_stack = array(); + foreach ( $this->execution_stack as $stream ) { + $name = array_search( $stream, $this->streams, true ); + $paused_execution_stack[] = $name; + } + return array( + 'streams' => $paused_streams, + 'execution_stack' => $paused_execution_stack, + ); + } + + public function resume( $paused_state ) { + foreach ( $paused_state['streams'] as $name => $stream ) { + $this->streams[ $name ]->resume( $stream ); + } + foreach ( $paused_state['execution_stack'] as $name ) { + $this->push_stream( $this->streams[ $name ] ); + } + } + + public function run_to_completion() { + $output = ''; + foreach ( $this as $chunk ) { + switch ( $chunk->get_chunk_type() ) { + case '#error': + return false; + case '#bytes': + $output .= $chunk->get_bytes(); + break; + } + } + return $output; + } + + /** + * ## Next chunk generation + * + * Pushes data through a chain of streams. Every downstream data chunk + * is fully processed before asking for more chunks upstream. + * + * For example, suppose we: + * + * * Send 3 HTTP requests, and each of them produces a ZIP file + * * Each ZIP file has 3 XML files inside + * * Each XML file is rewritten using the XML_Processor + * + * Once the HTTP client has produced the first ZIP file, we start processing it. + * The ZIP decoder may already have enough data to unzip three files, but we only + * produce the first chunk of the first file and pass it to the XML processor. + * Then we handle the second chunk of the first file, and so on, until the first + * file is fully processed. Only then we move to the second file. + * + * Then, once the ZIP decoder exhausted the data for the first ZIP file, we move + * to the second ZIP file, and so on. + * + * This way we can maintain a predictable $context variable that carries upstream + * metadata and exposes methods like skip_file(). + */ + protected function generate_next_chunk(): bool { + if ( $this->last_stream->is_eof() ) { + $this->state->finish(); + return false; + } + + while ( true ) { + $bytes = $this->state->consume_input_bytes(); + if ( null === $bytes || false === $bytes ) { + break; + } + $this->first_stream->append_bytes( + $bytes + ); + } + + if ( $this->is_eof() ) { + $this->first_stream->state->append_eof(); + } + + if ( empty( $this->execution_stack ) ) { + array_push( $this->execution_stack, $this->first_stream ); + } + + while ( count( $this->execution_stack ) ) { + // Unpeel the context stack until we find a stream that + // produces output. + $stream = $this->pop_stream(); + if ( $stream->is_eof() ) { + continue; + } + + if ( true !== $this->stream_next( $stream ) ) { + continue; + } + + // We've got output from the stream, yay! Let's + // propagate it downstream. + $this->push_stream( $stream ); + + $prev_stream = $stream; + for ( $i = count( $this->execution_stack ); $i < count( $this->streams_names ); $i++ ) { + $next_stream = $this->streams[ $this->streams_names[ $i ] ]; + if ( $prev_stream->is_eof() ) { + $next_stream->append_eof(); + } + + $next_stream->append_bytes( + $prev_stream->state->output_bytes, + $this->chunk_context + ); + if ( true !== $this->stream_next( $next_stream ) ) { + return false; + } + $this->push_stream( $next_stream ); + $prev_stream = $next_stream; + } + + // When the last process in the chain produces output, + // we write it to the output pipe and bale. + if ( $this->last_stream->is_eof() ) { + $this->state->finish(); + break; + } + $this->state->file_id = $this->last_stream->state->file_id; + $this->state->output_bytes = $this->last_stream->state->output_bytes; + return true; + } + + // We produced no output and the upstream pipe is EOF. + // We're done. + if ( $this->first_stream->is_eof() ) { + $this->finish(); + } + + return false; + } + + protected function finish() { + $this->state->finish(); + foreach ( $this->streams as $stream ) { + $stream->state->finish(); + } + } + + private function pop_stream(): WP_Byte_Stream { + $name = $this->streams_names[ count( $this->execution_stack ) - 1 ]; + unset( $this->chunk_context[ $name ] ); + return array_pop( $this->execution_stack ); + } + + private function push_stream( WP_Byte_Stream $stream ) { + array_push( $this->execution_stack, $stream ); + $name = $this->streams_names[ count( $this->execution_stack ) - 1 ]; + $this->chunk_context[ $name ] = $stream; + } + + private function stream_next( WP_Byte_Stream $stream ) { + $produced_output = $stream->next_bytes(); + if ( $stream->state->last_error ) { + $name = array_search( $stream, $this->streams, true ); + $this->state->last_error = "Process $name has crashed (" . $stream->state->last_error . ')'; + } + return $produced_output; + } + + // Iterator methods. These don't make much sense on a regular + // process class because they cannot pull more input chunks from + // the top of the stream like ProcessChain can. + + public function current(): mixed { + return $this; + } + + public function key(): mixed { + return $this->get_chunk_type(); + } + + public function rewind(): void { + $this->next(); + } + + private $should_stop_on_errors = false; + public function stop_on_errors( $should_stop_on_errors ) { + $this->should_stop_on_errors = $should_stop_on_errors; + } + + public function next(): void { + while ( ! $this->next_bytes() ) { + if ( $this->should_stop_on_errors && $this->state->last_error ) { + break; + } + if ( $this->is_eof() ) { + break; + } + usleep( 10000 ); + } + } + + public function valid(): bool { + return ! $this->is_eof() || ( $this->should_stop_on_errors && $this->state->last_error ); + } + + + // ArrayAccess on ProcessChain exposes specific + // sub-processes by their names. + public function offsetExists( $offset ): bool { + return isset( $this->chunk_context[ $offset ] ); + } + + public function offsetGet( $offset ): mixed { + return $this->chunk_context[ $offset ] ?? null; + } + + public function offsetSet( $offset, $value ): void { + // No op + } + + public function offsetUnset( $offset ): void { + // No op + } +} diff --git a/packages/playground/data-liberation/src/stream-api/WP_Stream_Paused_State.php b/packages/playground/data-liberation/src/stream-api/WP_Stream_Paused_State.php new file mode 100644 index 0000000000..a8e7c246a6 --- /dev/null +++ b/packages/playground/data-liberation/src/stream-api/WP_Stream_Paused_State.php @@ -0,0 +1,11 @@ +class = $class_name; + $this->data = $data; + } +} diff --git a/packages/playground/data-liberation/src/stream-api/WP_Stream_Processor.php b/packages/playground/data-liberation/src/stream-api/WP_Stream_Processor.php new file mode 100644 index 0000000000..a954a31cf0 --- /dev/null +++ b/packages/playground/data-liberation/src/stream-api/WP_Stream_Processor.php @@ -0,0 +1,8 @@ +> 6 ) | 0xC0 ); $byte2 = chr( $code_point & 0x3F | 0x80 ); - return "{$byte1}{$byte2}"; } diff --git a/packages/playground/data-liberation/src/xml-api/WP_XML_Decoder.php b/packages/playground/data-liberation/src/xml-api/WP_XML_Decoder.php new file mode 100644 index 0000000000..bcff61de2a --- /dev/null +++ b/packages/playground/data-liberation/src/xml-api/WP_XML_Decoder.php @@ -0,0 +1,222 @@ += $end ) { + break; + } + + $start_of_potential_reference_at = $next_character_reference_at + 1; + if ( $start_of_potential_reference_at >= $end ) { + // @todo This is an error. The document ended too early; consume the rest as plaintext, which is wrong. + break; + } + + /** + * First character after the opening `&`. + */ + $start_of_potential_reference = $text[ $start_of_potential_reference_at ]; + + /* + * If it's a named character reference, it will be one of the five mandated references. + * + * - `&` + * - `'` + * - `>` + * - `<` + * - `"` + * + * These all must be found within the five successive characters from the `&`. + * + * Example: + * + * ╭ ampersand at 9 = $end - 6 + * 'XML' ($end = 15) + * ╰───┴─ this length must be at least 5 long, + * which is $end - 5. + */ + if ( + $next_character_reference_at < $end - 5 && + ( + 'a' === $start_of_potential_reference || + 'g' === $start_of_potential_reference || + 'l' === $start_of_potential_reference || + 'q' === $start_of_potential_reference + ) + ) { + foreach ( array( + 'amp;' => '&', + 'apos;' => "'", + 'lt;' => '<', + 'gt;' => '>', + 'quot;' => '"', + ) as $name => $substitution ) { + if ( 0 === substr_compare( $text, $name, $next_character_reference_at, strlen( $name ) ) ) { + $decoded .= substr( $text, $was_at, $next_character_reference_at - $was_at ) . $substitution; + $at = $start_of_potential_reference_at + strlen( $name ); + $was_at = $at; + continue 2; + } + } + + // @todo This is an invalid document. It should be communicated. Treat as plaintext and continue. + ++$at; + continue; + } + + /* + * The shortest numerical character reference is four characters. + * + * Example: + * + * + */ + if ( '#' !== $start_of_potential_reference || $next_character_reference_at + 4 >= $end ) { + // @todo This is an error. This ampersand _must_ be encoded. Treat as plaintext and move on. + ++$at; + continue; + } + + $is_hex = 'x' === $text[ $start_of_potential_reference_at + 1 ]; + if ( $is_hex ) { + $zeros_at = $start_of_potential_reference_at + 2; + $base = 16; + $digit_chars = '0123456789abcdefABCDEF'; + $max_digits = 6; // `􏿿` + } else { + $zeros_at = $start_of_potential_reference_at + 1; + $base = 10; + $digit_chars = '0123456789'; + $max_digits = 7; // `􏿿` + } + + $zero_count = strspn( $text, '0', $zeros_at ); + $digits_at = $zeros_at + $zero_count; + $digit_count = strspn( $text, $digit_chars, $digits_at, $max_digits ); + $semi_at = $digits_at + $digit_count; + + if ( $digit_count === 0 || $semi_at >= $end || ';' !== $text[ $semi_at ] ) { + // @todo This is an error. Treat as plaintext and move on. + ++$at; + continue; + } + + $code_point = intval( substr( $text, $digits_at, $digit_count ), $base ); + $character_reference = WP_HTML_Decoder::code_point_to_utf8_bytes( $code_point ); + if ( '�' === $character_reference && 0xFFFD !== $code_point ) { + /* + * Stop processing if we got an invalid character AND the reference does not + * specifically refer code point FFFD (�). + * + * > It is a fatal error when an XML processor encounters an entity with an + * > encoding that it is unable to process. It is a fatal error if an XML entity + * > is determined (via default, encoding declaration, or higher-level protocol) + * > to be in a certain encoding but contains byte sequences that are not legal + * > in that encoding. Specifically, it is a fatal error if an entity encoded in + * > UTF-8 contains any ill-formed code unit sequences, as defined in section + * > 3.9 of Unicode [Unicode]. Unless an encoding is determined by a higher-level + * > protocol, it is also a fatal error if an XML entity contains no encoding + * > declaration and its content is not legal UTF-8 or UTF-16. + * + * See https://www.w3.org/TR/xml/#charencoding + */ + // @todo This is an error. Treat as plaintext and continue, which is wrong. + ++$at; + continue; + } + + $decoded .= substr( $text, $was_at, $at - $was_at ); + $decoded .= $character_reference; + $at = $semi_at + 1; + $was_at = $at; + } + + if ( 0 === $was_at ) { + return $text; + } + + if ( $was_at < $end ) { + $decoded .= substr( $text, $was_at, $end - $was_at ); + } + + return $decoded; + } + + /** + * Finds and parses the next entity in a given text starting after the + * given byte offset, and being entirely found within the given max length. + * + * @since {WP_VERSION} + * + * // @todo Implement this function. + * + * @param string $text Text in which to search for an XML entity. + * @param int $starting_byte_offset Start looking after this byte offset. + * @param int $ending_byte_offset Stop looking if entity is not fully contained before this byte offset. + * @param int|null $entity_at Optional. If provided, will be set to byte offset where entity was + * found, if found. Otherwise, will not be set. + * + * @return string|null Parsed entity, if parsed, otherwise `null`. + */ + public static function next_entity( string $text, int $starting_byte_offset, int $ending_byte_offset, int &$entity_at = null ): ?string { + $at = $starting_byte_offset; + $end = $ending_byte_offset; + + while ( $at < $end ) { + $remaining = $end - $at; + $amp_after = strcspn( $text, '&', $at, $remaining ); + + // There are no more possible entities. + if ( $amp_after === $remaining ) { + return null; + } + + /* + * @todo Move the decoding logic from `decode()` above into here, + * then call this function in a loop from `decode()`. + */ + + ++$at; + } + + return null; + } +} diff --git a/packages/playground/data-liberation/src/xml-api/WP_XML_Processor.php b/packages/playground/data-liberation/src/xml-api/WP_XML_Processor.php new file mode 100644 index 0000000000..bef650e33c --- /dev/null +++ b/packages/playground/data-liberation/src/xml-api/WP_XML_Processor.php @@ -0,0 +1,761 @@ +flush_processed_xml(); + + $new_bytes = $state->consume_input_bytes(); + if ( null !== $new_bytes ) { + $xml_processor->append_bytes( $new_bytes ); + } + $tokens_found = 0; + while ( $xml_processor->next_token() ) { + ++$tokens_found; + $node_visitor_callback( $xml_processor ); + } + + if ( $tokens_found > 0 ) { + $buffer .= $xml_processor->flush_processed_xml(); + } elseif ( + $tokens_found === 0 && + ! $xml_processor->is_paused_at_incomplete_input() && + $xml_processor->get_current_depth() === 0 + ) { + // We've reached the end of the document, let's finish up. + // @TODO: Fix this so it doesn't return the entire XML + $buffer .= $xml_processor->get_unprocessed_xml(); + $state->finish(); + } + + if ( ! strlen( $buffer ) ) { + return false; + } + + $state->output_bytes = $buffer; + return true; + } + ); + } + + + public function pause() { + return array( + 'xml' => $this->xml, + // @TODO: Include all the information below in the bookmark: + 'bytes_already_parsed' => $this->token_starts_at, + 'breadcrumbs' => $this->get_breadcrumbs(), + 'parser_context' => $this->get_parser_context(), + 'stack_of_open_elements' => $this->stack_of_open_elements, + ); + } + + public function resume( $paused ) { + $this->xml = $paused['xml']; + $this->stack_of_open_elements = $paused['stack_of_open_elements']; + $this->parser_context = $paused['parser_context']; + $this->bytes_already_parsed = $paused['bytes_already_parsed']; + $this->base_class_next_token(); + } + + /** + * Wipes out the processed XML and appends the next chunk of XML to + * any remaining unprocessed XML. + * + * @param string $next_chunk XML to append. + */ + public function append_bytes( string $next_chunk ) { + $this->xml .= $next_chunk; + } + + public function flush_processed_xml() { + $this->get_updated_xml(); + + $processed_xml = $this->get_processed_xml(); + $unprocessed_xml = $this->get_unprocessed_xml(); + + $breadcrumbs = $this->get_breadcrumbs(); + $parser_context = $this->get_parser_context(); + + $this->reset_state(); + + $this->xml = $unprocessed_xml; + $this->stack_of_open_elements = $breadcrumbs; + $this->parser_context = $parser_context; + $this->had_previous_chunks = true; + + return $processed_xml; + } + + /** + * Constructor. + * + * @since WP_VERSION + * + * @param string $xml XML to process. + */ + public function __construct( $xml, $breadcrumbs = array(), $parser_context = self::IN_PROLOG_CONTEXT ) { + parent::__construct( $xml ); + $this->stack_of_open_elements = $breadcrumbs; + $this->parser_context = $parser_context; + } + + public function get_parser_context() { + return $this->parser_context; + } + + /** + * Finds the next element matching the $query. + * + * This doesn't currently have a way to represent non-tags and doesn't process + * semantic rules for text nodes. For access to the raw tokens consider using + * WP_XML_Tag_Processor instead. + * + * @since WP_VERSION + * + * @param array|string|null $query { + * Optional. Which tag name to find, having which class, etc. Default is to find any tag. + * + * @type string|null $tag_name Which tag to find, or `null` for "any tag." + * @type int|null $match_offset Find the Nth tag matching all search criteria. + * 1 for "first" tag, 3 for "third," etc. + * Defaults to first tag. + * @type string[] $breadcrumbs DOM sub-path at which element is found, e.g. `array( 'FIGURE', 'IMG' )`. + * May also contain the wildcard `*` which matches a single element, e.g. `array( 'SECTION', '*' )`. + * } + * @return bool Whether a tag was matched. + */ + public function next_tag( $query = null ) { + if ( null === $query ) { + while ( $this->step() ) { + if ( '#tag' !== $this->get_token_type() ) { + continue; + } + + if ( ! $this->is_tag_closer() ) { + return true; + } + } + + return false; + } + + if ( is_string( $query ) ) { + $query = array( 'breadcrumbs' => array( $query ) ); + } + + if ( ! is_array( $query ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Please pass a query array to this function.' ), + 'WP_VERSION' + ); + return false; + } + + if ( ! ( array_key_exists( 'breadcrumbs', $query ) && is_array( $query['breadcrumbs'] ) ) ) { + while ( $this->step() ) { + if ( '#tag' !== $this->get_token_type() ) { + continue; + } + + if ( ! $this->is_tag_closer() ) { + return true; + } + } + + return false; + } + + if ( isset( $query['tag_closers'] ) && 'visit' === $query['tag_closers'] ) { + _doing_it_wrong( + __METHOD__, + __( 'Cannot visit tag closers in XML Processor.' ), + 'WP_VERSION' + ); + return false; + } + + $breadcrumbs = $query['breadcrumbs']; + $match_offset = isset( $query['match_offset'] ) ? (int) $query['match_offset'] : 1; + + while ( $match_offset > 0 && $this->step() ) { + if ( '#tag' !== $this->get_token_type() ) { + continue; + } + + if ( $this->matches_breadcrumbs( $breadcrumbs ) && 0 === --$match_offset ) { + return true; + } + } + + return false; + } + + /* + * Sets a bookmark in the XML document. + * + * Bookmarks represent specific places or tokens in the HTML + * document, such as a tag opener or closer. When applying + * edits to a document, such as setting an attribute, the + * text offsets of that token may shift; the bookmark is + * kept updated with those shifts and remains stable unless + * the entire span of text in which the token sits is removed. + * + * Release bookmarks when they are no longer needed. + * + * Example: + * + *

Surprising fact you may not know!

+ * ^ ^ + * \-|-- this `H2` opener bookmark tracks the token + * + *

Surprising fact you may no… + * ^ ^ + * \-|-- it shifts with edits + * + * Bookmarks provide the ability to seek to a previously-scanned + * place in the HTML document. This avoids the need to re-scan + * the entire document. + * + * Example: + * + * + * ^^^^ + * want to note this last item + * + * $p = new WP_HTML_Tag_Processor( $html ); + * $in_list = false; + * while ( $p->next_tag( array( 'tag_closers' => $in_list ? 'visit' : 'skip' ) ) ) { + * if ( 'UL' === $p->get_tag() ) { + * if ( $p->is_tag_closer() ) { + * $in_list = false; + * $p->set_bookmark( 'resume' ); + * if ( $p->seek( 'last-li' ) ) { + * $p->add_class( 'last-li' ); + * } + * $p->seek( 'resume' ); + * $p->release_bookmark( 'last-li' ); + * $p->release_bookmark( 'resume' ); + * } else { + * $in_list = true; + * } + * } + * + * if ( 'LI' === $p->get_tag() ) { + * $p->set_bookmark( 'last-li' ); + * } + * } + * + * Bookmarks intentionally hide the internal string offsets + * to which they refer. They are maintained internally as + * updates are applied to the HTML document and therefore + * retain their "position" - the location to which they + * originally pointed. The inability to use bookmarks with + * functions like `substr` is therefore intentional to guard + * against accidentally breaking the HTML. + * + * Because bookmarks allocate memory and require processing + * for every applied update, they are limited and require + * a name. They should not be created with programmatically-made + * names, such as "li_{$index}" with some loop. As a general + * rule they should only be created with string-literal names + * like "start-of-section" or "last-paragraph". + * + * Bookmarks are a powerful tool to enable complicated behavior. + * Consider double-checking that you need this tool if you are + * reaching for it, as inappropriate use could lead to broken + * HTML structure or unwanted processing overhead. + * + * @since WP_VERSION + * + * @param string $bookmark_name Identifies this particular bookmark. + * @return bool Whether the bookmark was successfully created. + */ + public function set_bookmark( $bookmark_name ) { + return parent::set_bookmark( "_{$bookmark_name}" ); + } + + /** + * Moves the internal cursor in the HTML Processor to a given bookmark's location. + * + * Be careful! Seeking backwards to a previous location resets the parser to the + * start of the document and reparses the entire contents up until it finds the + * sought-after bookmarked location. + * + * In order to prevent accidental infinite loops, there's a + * maximum limit on the number of times seek() can be called. + * + * @throws Exception When unable to allocate a bookmark for the next token in the input HTML document. + * + * @since WP_VERSION + * + * @param string $bookmark_name Jump to the place in the document identified by this bookmark name. + * @return bool Whether the internal cursor was successfully moved to the bookmark's location. + */ + public function seek( $bookmark_name ) { + // Flush any pending updates to the document before beginning. + $this->get_updated_xml(); + return parent::seek( "_{$bookmark_name}" ); + } + + /** + * Removes a bookmark that is no longer needed. + * + * Releasing a bookmark frees up the small + * performance overhead it requires. + * + * @since WP_VERSION + * + * @param string $bookmark_name Name of the bookmark to remove. + * @return bool Whether the bookmark already existed before removal. + */ + public function release_bookmark( $bookmark_name ) { + return parent::release_bookmark( "_{$bookmark_name}" ); + } + + /** + * Checks whether a bookmark with the given name exists. + * + * @since 6.5.0 + * + * @param string $bookmark_name Name to identify a bookmark that potentially exists. + * @return bool Whether that bookmark exists. + */ + public function has_bookmark( $bookmark_name ) { + return parent::has_bookmark( "_{$bookmark_name}" ); + } + + /** + * Low-level token iteration is not available in WP_XML_Processor + * as it could lead to undefined behaviors. + * + * @use WP_XML_Processor::next_tag() instead. + * + * @return false + */ + public function next_token() { + return $this->step(); + } + + /** + * Steps through the XML document and stop at the next tag, if any. + * + * @since WP_VERSION + * + * @param string $node_to_process Whether to parse the next node or reprocess the current node. + * @return bool Whether a tag was matched. + */ + private function step( $node_to_process = self::PROCESS_NEXT_NODE ) { + // Refuse to proceed if there was a previous error. + if ( null !== $this->last_error ) { + return false; + } + + // Finish stepping when there are no more tokens in the document. + if ( + WP_XML_Tag_Processor::STATE_INCOMPLETE_INPUT === $this->parser_state || + WP_XML_Tag_Processor::STATE_COMPLETE === $this->parser_state + ) { + return false; + } + + if ( self::PROCESS_NEXT_NODE === $node_to_process ) { + if ( $this->is_empty_element() ) { + $this->pop_open_element(); + } + $this->base_class_next_token(); + } + + static $i = 0; + switch ( $this->parser_context ) { + case self::IN_PROLOG_CONTEXT: + return $this->step_in_prolog(); + case self::IN_ELEMENT_CONTEXT: + return $this->step_in_element(); + case self::IN_MISC_CONTEXT: + return $this->step_in_misc(); + default: + $this->last_error = self::ERROR_UNSUPPORTED; + return false; + } + } + + /** + * Parses the next node in the 'prolog' part of the XML document. + * + * @since WP_VERSION + * + * @see https://www.w3.org/TR/xml/#NT-document. + * @see WP_XML_Tag_Processor::step + * + * @return bool Whether a node was found. + */ + private function step_in_prolog() { + // XML requires a root element. If we've reached the end of data in the prolog stage, + // before finding a root element, then the document is incomplete. + if ( WP_XML_Tag_Processor::STATE_COMPLETE === $this->parser_state ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + return false; + } + // Do not step if we paused due to an incomplete input. + if ( WP_XML_Tag_Processor::STATE_INCOMPLETE_INPUT === $this->parser_state ) { + return false; + } + switch ( $this->get_token_type() ) { + case '#text': + $text = $this->get_modifiable_text(); + $whitespaces = strspn( $text, " \t\n\r" ); + if ( strlen( $text ) !== $whitespaces ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( __METHOD__, 'Unexpected token type in prolog stage.', 'WP_VERSION' ); + } + + return $this->step(); + case '#xml-declaration': + case '#comment': + case '#processing-instructions': + return true; + case '#tag': + $this->parser_context = self::IN_ELEMENT_CONTEXT; + return $this->step( self::PROCESS_CURRENT_NODE ); + default: + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( __METHOD__, 'Unexpected token type in prolog stage.', 'WP_VERSION' ); + return false; + } + } + + /** + * Parses the next node in the 'element' part of the XML document. + * + * @since WP_VERSION + * + * @see https://www.w3.org/TR/xml/#NT-document. + * @see WP_XML_Tag_Processor::step + * + * @return bool Whether a node was found. + */ + private function step_in_element() { + // An XML document isn't complete until the root element is closed. + if ( self::STATE_COMPLETE === $this->parser_state && + count( $this->stack_of_open_elements ) > 0 + ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + return false; + } + // Do not step if we paused due to an incomplete input. + if ( WP_XML_Tag_Processor::STATE_INCOMPLETE_INPUT === $this->parser_state ) { + return false; + } + + switch ( $this->get_token_type() ) { + case '#text': + case '#cdata-section': + case '#comment': + case '#processing-instructions': + return true; + case '#tag': + // Update the stack of open elements + $tag_name = $this->get_tag(); + if ( $this->is_tag_closer() ) { + $popped = $this->pop_open_element(); + if ( $popped !== $tag_name ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'The closing tag did not match the opening tag.' ), + 'WP_VERSION' + ); + return false; + } + if ( count( $this->stack_of_open_elements ) === 0 ) { + $this->parser_context = self::IN_MISC_CONTEXT; + } + } else { + $this->push_open_element( $tag_name ); + } + return true; + default: + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( __METHOD__, 'Unexpected token type in element stage.', 'WP_VERSION' ); + return false; + } + } + + /** + * Parses the next node in the 'misc' part of the XML document. + * + * @since WP_VERSION + * + * @see https://www.w3.org/TR/xml/#NT-document. + * @see WP_XML_Tag_Processor::step + * + * @return bool Whether a node was found. + */ + private function step_in_misc() { + if ( self::STATE_COMPLETE === $this->parser_state ) { + return true; + } + + switch ( $this->get_token_type() ) { + case '#comment': + case '#processing-instructions': + return true; + case '#text': + $text = $this->get_modifiable_text(); + $whitespaces = strspn( $text, " \t\n\r" ); + if ( strlen( $text ) !== $whitespaces ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( __METHOD__, 'Unexpected token type in prolog stage.', 'WP_VERSION' ); + return false; + } + return $this->step(); + default: + /* + * If we're at the end of the document, we can never be sure + * whether it's complete or are we still waiting for a comment + * or a processing directive. Let's mark the parse as complete + * and let the API consumer decide whether they want to re-parse + * once more data becomes available in. + */ + if ( + WP_XML_Tag_Processor::STATE_INCOMPLETE_INPUT === $this->parser_state && + $this->is_incomplete_text_node + ) { + $text = $this->get_modifiable_text(); + // Non-whitespace characters are not allowed after the root element was closed. + $contains_only_whitespace = strlen( $text ) === strspn( $text, " \t\n\r" ); + if ( $contains_only_whitespace ) { + $this->parser_state = self::STATE_COMPLETE; + return false; + } + } + + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( __METHOD__, 'Unexpected token type in misc stage.', 'WP_VERSION' ); + return false; + } + } + + /** + * Computes the XML breadcrumbs for the currently-matched element, if matched. + * + * Breadcrumbs start at the outermost parent and descend toward the matched element. + * They always include the entire path from the root XML node to the matched element. + + * Example + * + * $processor = WP_XML_Processor::create_fragment( '

' ); + * $processor->next_tag( 'img' ); + * $processor->get_breadcrumbs() === array( 'p', 'strong', 'em', 'img' ); + * + * @since WP_VERSION + * + * @return string[]|null Array of tag names representing path to matched node, if matched, otherwise NULL. + */ + public function get_breadcrumbs() { + return $this->stack_of_open_elements; + } + + /** + * Indicates if the currently-matched tag matches the given breadcrumbs. + * + * A "*" represents a single tag wildcard, where any tag matches, but not no tags. + * + * At some point this function _may_ support a `**` syntax for matching any number + * of unspecified tags in the breadcrumb stack. This has been intentionally left + * out, however, to keep this function simple and to avoid introducing backtracking, + * which could open up surprising performance breakdowns. + * + * Example: + * + * $processor = new WP_XML_Tag_Processor( '' ); + * $processor->next_tag( 'img' ); + * true === $processor->matches_breadcrumbs( array( 'content', 'image' ) ); + * true === $processor->matches_breadcrumbs( array( 'wp:post', 'content', 'image' ) ); + * false === $processor->matches_breadcrumbs( array( 'wp:post', 'image' ) ); + * true === $processor->matches_breadcrumbs( array( 'wp:post', '*', 'image' ) ); + * + * @since WP_VERSION + * + * @param string[] $breadcrumbs DOM sub-path at which element is found, e.g. `array( 'content', 'image' )`. + * May also contain the wildcard `*` which matches a single element, e.g. `array( 'wp:post', '*' )`. + * @return bool Whether the currently-matched tag is found at the given nested structure. + */ + public function matches_breadcrumbs( $breadcrumbs ) { + // Everything matches when there are zero constraints. + if ( 0 === count( $breadcrumbs ) ) { + return true; + } + + // Start at the last crumb. + $crumb = end( $breadcrumbs ); + + if ( + '#tag' === $this->get_token_type() && + '*' !== $crumb && + $this->get_tag() !== $crumb + ) { + return false; + } + + for ( $i = count( $this->stack_of_open_elements ) - 1; $i >= 0; $i-- ) { + $tag_name = $this->stack_of_open_elements[ $i ]; + $crumb = current( $breadcrumbs ); + + if ( '*' !== $crumb && $tag_name !== $crumb ) { + return false; + } + + if ( false === prev( $breadcrumbs ) ) { + return true; + } + } + + return false; + } + + /** + * Returns the nesting depth of the current location in the document. + * + * Example: + * + * $processor = new WP_XML_Processor( '' ); + * 0 === $processor->get_current_depth(); + * + * // Opening the root element increases the depth. + * $processor->next_tag(); + * 1 === $processor->get_current_depth(); + * + * // Opening the wp:text element increases the depth. + * $processor->next_tag(); + * 2 === $processor->get_current_depth(); + * + * // The wp:text element is closed during `next_token()` so the depth is decreased to reflect that. + * $processor->next_token(); + * 1 === $processor->get_current_depth(); + * + * @since WP_VERSION + * + * @return int Nesting-depth of current location in the document. + */ + public function get_current_depth() { + return count( $this->stack_of_open_elements ); + } + + private function pop_open_element() { + return array_pop( $this->stack_of_open_elements ); + } + + private function push_open_element( $tag_name ) { + array_push( + $this->stack_of_open_elements, + $tag_name + ); + } + + private function last_open_element() { + return end( $this->stack_of_open_elements ); + } + + /** + * Indicates that we're parsing the `prolog` part of the XML + * document. + * + * @since WP_VERSION + * + * @access private + */ + const IN_PROLOG_CONTEXT = 'prolog'; + + /** + * Indicates that we're parsing the `element` part of the XML + * document. + * + * @since WP_VERSION + * + * @access private + */ + const IN_ELEMENT_CONTEXT = 'element'; + + /** + * Indicates that we're parsing the `misc` part of the XML + * document. + * + * @since WP_VERSION + * + * @access private + */ + const IN_MISC_CONTEXT = 'misc'; + + /** + * Indicates that the next HTML token should be parsed and processed. + * + * @since WP_VERSION + * + * @var string + */ + const PROCESS_NEXT_NODE = 'process-next-node'; + + /** + * Indicates that the current HTML token should be processed without advancing the parser. + * + * @since WP_VERSION + * + * @var string + */ + const PROCESS_CURRENT_NODE = 'process-current-node'; +} diff --git a/packages/playground/data-liberation/src/xml-api/WP_XML_Tag_Processor.php b/packages/playground/data-liberation/src/xml-api/WP_XML_Tag_Processor.php new file mode 100644 index 0000000000..26ac382e2e --- /dev/null +++ b/packages/playground/data-liberation/src/xml-api/WP_XML_Tag_Processor.php @@ -0,0 +1,2837 @@ +). We're + * starting with 1.0, however, because most that's what most WXR + * files declare. + * + * ## Future work + * + * @TODO: Skip over the following syntax elements: + * * + * + * or + * + * + * + * ' > + * %xx; + * ]> + * + * @TODO: Support XML 1.1. + * @package WordPress + * @subpackage HTML-API + * @since WP_VERSION + */ + +/** + * Core class used to modify attributes in an XML document for tags matching a query. + * + * ## Usage + * + * Use of this class requires three steps: + * + * 1. Create a new class instance with your input XML document. + * 2. Find the tag(s) you are looking for. + * 3. Request changes to the attributes in those tag(s). + * + * Example: + * + * $tags = new WP_XML_Tag_Processor( $xml ); + * if ( $tags->next_tag( 'wp:option' ) ) { + * $tags->set_attribute( 'selected', 'yes' ); + * } + * + * ### Finding tags + * + * The `next_tag()` function moves the internal cursor through + * your input XML document until it finds a tag meeting any of + * the supplied restrictions in the optional query argument. If + * no argument is provided then it will find the next XML tag, + * regardless of what kind it is. + * + * If you want to _find whatever the next tag is_: + * + * $tags->next_tag(); + * + * | Goal | Query | + * |-----------------------------------------------------------|---------------------------------------------------------------------------------| + * | Find any tag. | `$tags->next_tag();` | + * | Find next image tag. | `$tags->next_tag( array( 'tag_name' => 'wp:image' ) );` | + * | Find next image tag (without passing the array). | `$tags->next_tag( 'wp:image' );` | + * + * If a tag was found meeting your criteria then `next_tag()` + * will return `true` and you can proceed to modify it. If it + * returns `false`, however, it failed to find the tag and + * moved the cursor to the end of the file. + * + * Once the cursor reaches the end of the file the processor + * is done and if you want to reach an earlier tag you will + * need to recreate the processor and start over, as it's + * unable to back up or move in reverse. + * + * See the section on bookmarks for an exception to this + * no-backing-up rule. + * + * #### Custom queries + * + * Sometimes it's necessary to further inspect an XML tag than + * the query syntax here permits. In these cases one may further + * inspect the search results using the read-only functions + * provided by the processor or external state or variables. + * + * Example: + * + * // Paint up to the first five `wp:musician` or `wp:actor` tags marked with the "jazzy" style. + * $remaining_count = 5; + * while ( $remaining_count > 0 && $tags->next_tag() ) { + * if ( + * ( 'wp:musician' === $tags->get_tag() || 'wp:actor' === $tags->get_tag() ) && + * 'jazzy' === $tags->get_attribute( 'data-style' ) + * ) { + * $tags->set_attribute( 'wp:theme-style', 'theme-style-everest-jazz' ); + * $remaining_count--; + * } + * } + * + * `get_attribute()` will return `null` if the attribute wasn't present + * on the tag when it was called. It may return `""` (the empty string) + * in cases where the attribute was present but its value was empty. + * For boolean attributes, those whose name is present but no value is + * given, it will return `true` (the only way to set `false` for an + * attribute is to remove it). + * + * #### When matching fails + * + * When `next_tag()` returns `false` it could mean different things: + * + * - The requested tag wasn't found in the input document. + * - The input document ended in the middle of an XML syntax element. + * + * When a document ends in the middle of a syntax element it will pause + * the processor. This is to make it possible in the future to extend the + * input document and proceed - an important requirement for chunked + * streaming parsing of a document. + * + * Example: + * + * $processor = new WP_XML_Tag_Processor( 'This next_tag( array( 'tag_name' => 'wp:todo-list' ) ) ) { + * $p->set_bookmark( 'list-start' ); + * while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + * if ( 'wp:todo' === $p->get_tag() && $p->is_tag_closer() ) { + * $p->set_bookmark( 'list-end' ); + * $p->seek( 'list-start' ); + * $p->set_attribute( 'data-contained-todos', (string) $total_todos ); + * $total_todos = 0; + * $p->seek( 'list-end' ); + * break; + * } + * + * if ( 'wp:todo-item' === $p->get_tag() && ! $p->is_tag_closer() ) { + * $total_todos++; + * } + * } + * } + * + * ## Tokens and finer-grained processing. + * + * It's possible to scan through every lexical token in the + * XML document using the `next_token()` function. This + * alternative form takes no argument and provides no built-in + * query syntax. + * + * Example: + * + * $title = '(untitled)'; + * $text = ''; + * while ( $processor->next_token() ) { + * switch ( $processor->get_token_name() ) { + * case '#text': + * $text .= $processor->get_modifiable_text(); + * break; + * + * case 'wp:new-line': + * $text .= "\n"; + * break; + * + * case 'wp:title': + * $title = $processor->get_modifiable_text(); + * break; + * } + * } + * return trim( "# {$title}\n\n{$text}" ); + * + * ### Tokens and _modifiable text_. + * + * #### Other tokens with modifiable text. + * + * There are also non-elements which are void/self-closing in nature and contain + * modifiable text that is part of that individual syntax token itself. + * + * - `#text` nodes, whose entire token _is_ the modifiable text. + * - XML comments and tokens that become comments due to some syntax error. The + * text for these tokens is the portion of the comment inside of the syntax. + * E.g. for `` the text is `" comment "` (note the spaces are included). + * - `CDATA` sections, whose text is the content inside of the section itself. E.g. for + * `` the text is `"some content"`. + * - XML Processing instruction nodes like `` (with restrictions [1]). + * + * [1]: XML requires "xml" as a processing instruction name. The Tag Processor captures the entire + * processing instruction as a single token up to the closing `?>`. + * + * ## Design and limitations + * + * The Tag Processor is designed to linearly scan XML documents and tokenize + * XML tags and their attributes. It's designed to do this as efficiently as + * possible without compromising parsing integrity. Therefore it will be + * slower than some methods of modifying XML, such as those incorporating + * over-simplified PCRE patterns, but will not introduce the defects and + * failures that those methods bring in, which lead to broken page renders + * and often to security vulnerabilities. On the other hand, it will be faster + * than full-blown XML parsers such as DOMDocument and use considerably + * less memory. It requires a negligible memory overhead, enough to consider + * it a zero-overhead system. + * + * The performance characteristics are maintained by avoiding tree construction. + * + * The Tag Processor's checks the most important aspects of XML integrity as it scans + * through the document. It verifies that a single root element exists, that are + * no unclosed tags, and that each opener tag has a corresponding closer. It also + * ensures no duplicate attributes exist on a single tag. + * + * At the same time, The Tag Processor also skips expensive validation of XML entities + * in the document. The Tag Processor will initially pass through the invalid entity references + * and only fail when the developer attempts to read their value. If that doesn't happen, + * the invalid values will be left untouched in the final document. + * + * Most operations within the Tag Processor are designed to minimize the difference + * between an input and output document for any given change. For example, the + * `set_attribure` and `remove_attribute` methods preserve whitespace and the attribute + * ordering within the element definition. An exception to this rule is that all attribute + * updates store their values as double-quoted strings, meaning that attributes on input with + * single-quoted or unquoted values will appear in the output with double-quotes. + * + * ### Text Encoding + * + * The Tag Processor assumes that the input XML document is encoded with a + * UTF-8 encoding and will refuse to process documents that declare other encodings. + * + * @since WP_VERSION + */ +class WP_XML_Tag_Processor { + /** + * The maximum number of bookmarks allowed to exist at + * any given time. + * + * @since WP_VERSION + * @var int + * + * @see WP_XML_Tag_Processor::set_bookmark() + */ + const MAX_BOOKMARKS = 10; + + /** + * Maximum number of times seek() can be called. + * Prevents accidental infinite loops. + * + * @since WP_VERSION + * @var int + * + * @see WP_XML_Tag_Processor::seek() + */ + const MAX_SEEK_OPS = 1000; + + /** + * The XML document to parse. + * + * @since WP_VERSION + * @var string + */ + public $xml; + + /** + * The last query passed to next_tag(). + * + * @since WP_VERSION + * @var array|null + */ + private $last_query; + + /** + * The tag name this processor currently scans for. + * + * @since WP_VERSION + * @var string|null + */ + private $sought_tag_name; + + /** + * The match offset this processor currently scans for. + * + * @since WP_VERSION + * @var int|null + */ + private $sought_match_offset; + + /** + * Whether to visit tag closers, e.g. , when walking an input document. + * + * @since WP_VERSION + * @var bool + */ + private $stop_on_tag_closers; + + /** + * Specifies mode of operation of the parser at any given time. + * + * | State | Meaning | + * | ----------------|------------------------------------------------------------------------| + * | *Ready* | The parser is ready to run. | + * | *Complete* | There is nothing left to parse. | + * | *Incomplete* | The XML ended in the middle of a token; nothing more can be parsed. | + * | *Matched tag* | Found an XML tag; it's possible to modify its attributes. | + * | *Text node* | Found a #text node; this is plaintext and modifiable. | + * | *CDATA node* | Found a CDATA section; this is modifiable. | + * | *PI node* | Found a processing instruction; this is modifiable. | + * | *XML declaration* | Found an XML declaration; this is modifiable. | + * | *Comment* | Found a comment or bogus comment; this is modifiable. | + * + * @since WP_VERSION + * + * @see WP_XML_Tag_Processor::STATE_READY + * @see WP_XML_Tag_Processor::STATE_COMPLETE + * @see WP_XML_Tag_Processor::STATE_INCOMPLETE_INPUT + * @see WP_XML_Tag_Processor::STATE_MATCHED_TAG + * @see WP_XML_Tag_Processor::STATE_TEXT_NODE + * @see WP_XML_Tag_Processor::STATE_CDATA_NODE + * @see WP_XML_Tag_Processor::STATE_PI_NODE + * @see WP_XML_Tag_Processor::STATE_XML_DECLARATION + * @see WP_XML_Tag_Processor::STATE_COMMENT + * + * @var string + */ + protected $parser_state = self::STATE_READY; + + /** + * Whether we stopped at an incomplete text node. + * + * If we are before the last tag in the document, every text + * node is incomplete until we find the next tag. However, + * if we are after the last tag, an incomplete all-whitespace + * node may either mean we're the end of the document or + * that we're still waiting for more data/ + * + * This flag allows us to differentiate between these two + * cases in context-aware APIs such as WP_XML_Processor. + * + * @var bool + */ + protected $is_incomplete_text_node = false; + + /** + * How many bytes from the original XML document have been read and parsed. + * + * This value points to the latest byte offset in the input document which + * has been already parsed. It is the internal cursor for the Tag Processor + * and updates while scanning through the XML tokens. + * + * @since WP_VERSION + * @var int + */ + public $bytes_already_parsed = 0; + + /** + * Byte offset in input document where current token starts. + * + * Example: + * + * ... + * 01234 + * - token starts at 0 + * + * @since WP_VERSION + * + * @var int|null + */ + protected $token_starts_at; + + /** + * Byte length of current token. + * + * Example: + * + * ... + * 012345678901234 + * - token length is 14 - 0 = 14 + * + * a is a token. + * 0123456789 123456789 123456789 + * - token length is 17 - 2 = 15 + * + * @since WP_VERSION + * + * @var int|null + */ + private $token_length; + + /** + * Byte offset in input document where current tag name starts. + * + * Example: + * + * ... + * 01234 + * - tag name starts at 1 + * + * @since WP_VERSION + * + * @var int|null + */ + private $tag_name_starts_at; + + /** + * Byte length of current tag name. + * + * Example: + * + * ... + * 01234 + * --- tag name length is 3 + * + * @since WP_VERSION + * + * @var int|null + */ + private $tag_name_length; + + /** + * Byte offset into input document where current modifiable text starts. + * + * @since WP_VERSION + * + * @var int + */ + private $text_starts_at; + + /** + * Byte length of modifiable text. + * + * @since WP_VERSION + * + * @var string + */ + private $text_length; + + /** + * Whether the current tag is an opening tag, e.g. , or a closing tag, e.g. . + * + * @var bool + */ + private $is_closing_tag; + + /** + * Stores an explanation for why something failed, if it did. + * + * @see self::get_last_error + * + * @since WP_VERSION + * + * @var string|null + */ + protected $last_error = null; + + /** + * Lazily-built index of attributes found within an XML tag, keyed by the attribute name. + * + * Example: + * + * // Supposing the parser is working through this content + * // and stops after recognizing the `id` attribute. + * // + * // ^ parsing will continue from this point. + * $this->attributes = array( + * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ) + * ); + * + * // When picking up parsing again, or when asking to find the + * // `class` attribute we will continue and add to this array. + * $this->attributes = array( + * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ), + * 'class' => new WP_HTML_Attribute_Token( 'class', 23, 7, 17, 13, false ) + * ); + * + * @since WP_VERSION + * @var WP_HTML_Attribute_Token[] + */ + private $attributes = array(); + + /** + * Tracks a semantic location in the original XML which + * shifts with updates as they are applied to the document. + * + * @since WP_VERSION + * @var WP_HTML_Span[] + */ + protected $bookmarks = array(); + + /** + * Lexical replacements to apply to input XML document. + * + * "Lexical" in this class refers to the part of this class which + * operates on pure text _as text_ and not as XML. There's a line + * between the public interface, with XML-semantic methods like + * `set_attribute` and `add_class`, and an internal state that tracks + * text offsets in the input document. + * + * When higher-level XML methods are called, those have to transform their + * operations (such as setting an attribute's value) into text diffing + * operations (such as replacing the sub-string from indices A to B with + * some given new string). These text-diffing operations are the lexical + * updates. + * + * As new higher-level methods are added they need to collapse their + * operations into these lower-level lexical updates since that's the + * Tag Processor's internal language of change. Any code which creates + * these lexical updates must ensure that they do not cross XML syntax + * boundaries, however, so these should never be exposed outside of this + * class or any classes which intentionally expand its functionality. + * + * These are enqueued while editing the document instead of being immediately + * applied to avoid processing overhead, string allocations, and string + * copies when applying many updates to a single document. + * + * Example: + * + * // Replace an attribute stored with a new value, indices + * // sourced from the lazily-parsed XML recognizer. + * $start = $attributes['src']->start; + * $length = $attributes['src']->length; + * $modifications[] = new WP_HTML_Text_Replacement( $start, $length, $new_value ); + * + * // Correspondingly, something like this will appear in this array. + * $lexical_updates = array( + * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) + * ); + * + * @since WP_VERSION + * @var WP_HTML_Text_Replacement[] + */ + protected $lexical_updates = array(); + + /** + * Tracks and limits `seek()` calls to prevent accidental infinite loops. + * + * @since WP_VERSION + * @var int + * + * @see WP_XML_Tag_Processor::seek() + */ + protected $seek_count = 0; + + public $had_previous_chunks = false; + + /** + * Constructor. + * + * @since WP_VERSION + * + * @param string $xml XML to process. + */ + public function __construct( $xml ) { + $this->xml = $xml; + } + + /** + * Finds the next element matching the $query. + * + * This doesn't currently have a way to represent non-tags and doesn't process + * semantic rules for text nodes. + * + * @since WP_VERSION + * + * @param array|string|null $query { + * Optional. Which element name to find. Default is to find any tag. + * + * @type string|null $tag_name Which tag to find, or `null` for "any tag." + * @type int|null $match_offset Find the Nth tag matching all search criteria. + * 1 for "first" tag, 3 for "third," etc. + * Defaults to first tag. + * @type string|null $tag_closers "visit" or "skip": whether to stop on tag closers, e.g. . + * } + * @return bool Whether a tag was matched. + */ + public function next_tag( $query = null ) { + $this->parse_query( $query ); + $already_found = 0; + + do { + if ( false === $this->base_class_next_token() ) { + return false; + } + + if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { + continue; + } + + if ( $this->matches() ) { + ++$already_found; + } + } while ( $already_found < $this->sought_match_offset ); + + return true; + } + + /** + * Finds the next token in the XML document. + * + * An XML document can be viewed as a stream of tokens, + * where tokens are things like XML tags, XML comments, + * text nodes, etc. This method finds the next token in + * the XML document and returns whether it found one. + * + * If it starts parsing a token and reaches the end of the + * document then it will seek to the start of the last + * token and pause, returning `false` to indicate that it + * failed to find a complete token. + * + * Possible token types, based on the XML specification: + * + * - an XML tag, whether opening, closing, or void. + * - a text node - the plaintext inside tags. + * - an XML comment. + * - a processing instruction, e.g. ``. + * + * The Tag Processor currently only supports the tag token. + * + * @since WP_VERSION + * + * @access private + * + * @return bool Whether a token was parsed. + */ + public function next_token() { + return $this->base_class_next_token(); + } + + /** + * Internal method which finds the next token in the HTML document. + * + * This method is a protected internal function which implements the logic for + * finding the next token in a document. It exists so that the parser can update + * its state without affecting the location of the cursor in the document and + * without triggering subclass methods for things like `next_token()`, e.g. when + * applying patches before searching for the next token. + * + * @since 6.5.0 + * + * @access private + * + * @return bool Whether a token was parsed. + */ + protected function base_class_next_token() { + $was_at = $this->bytes_already_parsed; + $this->after_tag(); + + // Don't proceed if there's nothing more to scan. + if ( + self::STATE_COMPLETE === $this->parser_state || + self::STATE_INCOMPLETE_INPUT === $this->parser_state || + null !== $this->last_error + ) { + return false; + } + + /* + * The next step in the parsing loop determines the parsing state; + * clear it so that state doesn't linger from the previous step. + */ + $this->parser_state = self::STATE_READY; + + if ( $this->bytes_already_parsed >= strlen( $this->xml ) ) { + $this->parser_state = self::STATE_COMPLETE; + return false; + } + + // Find the next tag if it exists. + if ( false === $this->parse_next_tag() ) { + if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { + $this->bytes_already_parsed = $was_at; + } + + return false; + } + + if ( null !== $this->last_error ) { + return false; + } + + /* + * For legacy reasons the rest of this function handles tags and their + * attributes. If the processor has reached the end of the document + * or if it matched any other token then it should return here to avoid + * attempting to process tag-specific syntax. + */ + if ( + self::STATE_INCOMPLETE_INPUT !== $this->parser_state && + self::STATE_COMPLETE !== $this->parser_state && + self::STATE_MATCHED_TAG !== $this->parser_state + ) { + return true; + } + + if ( $this->is_closing_tag ) { + $this->skip_whitespace(); + } else { + // Parse all of its attributes. + while ( $this->parse_next_attribute() ) { + continue; + } + } + + if ( null !== $this->last_error ) { + return false; + } + + // Ensure that the tag closes before the end of the document. + if ( + self::STATE_INCOMPLETE_INPUT === $this->parser_state || + $this->bytes_already_parsed >= strlen( $this->xml ) + ) { + // Does this appropriately clear state (parsed attributes)? + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + $this->bytes_already_parsed = $was_at; + + return false; + } + + $tag_ends_at = strpos( $this->xml, '>', $this->bytes_already_parsed ); + if ( false === $tag_ends_at ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + $this->bytes_already_parsed = $was_at; + + return false; + } + + if ( $this->is_closing_tag && $tag_ends_at !== $this->bytes_already_parsed ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'Invalid closing tag encountered.' ), + 'WP_VERSION' + ); + return false; + } + + $this->parser_state = self::STATE_MATCHED_TAG; + $this->bytes_already_parsed = $tag_ends_at + 1; + $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; + + /* + * If we are in a PCData element, everything until the closer + * is considered text. + */ + if ( ! $this->is_pcdata_element() ) { + return true; + } + + /* + * Preserve the opening tag pointers, as these will be overwritten + * when finding the closing tag. They will be reset after finding + * the closing to tag to point to the opening of the special atomic + * tag sequence. + */ + $tag_name_starts_at = $this->tag_name_starts_at; + $tag_name_length = $this->tag_name_length; + $tag_ends_at = $this->token_starts_at + $this->token_length; + $attributes = $this->attributes; + + $found_closer = $this->skip_pcdata( $this->get_tag() ); + + // Closer not found, the document is incomplete. + if ( false === $found_closer ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + $this->bytes_already_parsed = $was_at; + return false; + } + + /* + * The values here look like they reference the opening tag but they reference + * the closing tag instead. This is why the opening tag values were stored + * above in a variable. It reads confusingly here, but that's because the + * functions that skip the contents have moved all the internal cursors past + * the inner content of the tag. + */ + $this->token_starts_at = $was_at; + $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; + $this->text_starts_at = $tag_ends_at; + $this->text_length = $this->tag_name_starts_at - $this->text_starts_at; + $this->tag_name_starts_at = $tag_name_starts_at; + $this->tag_name_length = $tag_name_length; + $this->attributes = $attributes; + + return true; + } + + /** + * Whether the processor paused because the input XML document ended + * in the middle of a syntax element, such as in the middle of a tag. + * + * Example: + * + * $processor = new WP_XML_Tag_Processor( '

Surprising fact you may no… + * ^ ^ + * \-|-- it shifts with edits + * + * Bookmarks provide the ability to seek to a previously-scanned + * place in the XML document. This avoids the need to re-scan + * the entire document. + * + * Example: + * + *
  • One
  • Two
  • Three
+ * ^^^^ + * want to note this last item + * + * $p = new WP_XML_Tag_Processor( $xml ); + * $in_list = false; + * while ( $p->next_tag( array( 'tag_closers' => $in_list ? 'visit' : 'skip' ) ) ) { + * if ( 'UL' === $p->get_tag() ) { + * if ( $p->is_tag_closer() ) { + * $in_list = false; + * $p->set_bookmark( 'resume' ); + * if ( $p->seek( 'last-li' ) ) { + * $p->add_class( 'last-li' ); + * } + * $p->seek( 'resume' ); + * $p->release_bookmark( 'last-li' ); + * $p->release_bookmark( 'resume' ); + * } else { + * $in_list = true; + * } + * } + * + * if ( 'LI' === $p->get_tag() ) { + * $p->set_bookmark( 'last-li' ); + * } + * } + * + * Bookmarks intentionally hide the internal string offsets + * to which they refer. They are maintained internally as + * updates are applied to the XML document and therefore + * retain their "position" - the location to which they + * originally pointed. The inability to use bookmarks with + * functions like `substr` is therefore intentional to guard + * against accidentally breaking the XML. + * + * Because bookmarks allocate memory and require processing + * for every applied update, they are limited and require + * a name. They should not be created with programmatically-made + * names, such as "li_{$index}" with some loop. As a general + * rule they should only be created with string-literal names + * like "start-of-section" or "last-paragraph". + * + * Bookmarks are a powerful tool to enable complicated behavior. + * Consider double-checking that you need this tool if you are + * reaching for it, as inappropriate use could lead to broken + * XML structure or unwanted processing overhead. + * + * @since WP_VERSION + * + * @param string $name Identifies this particular bookmark. + * @return bool Whether the bookmark was successfully created. + */ + public function set_bookmark( $name ) { + // It only makes sense to set a bookmark if the parser has paused on a concrete token. + if ( + self::STATE_COMPLETE === $this->parser_state || + self::STATE_INCOMPLETE_INPUT === $this->parser_state + ) { + return false; + } + + if ( ! array_key_exists( $name, $this->bookmarks ) && count( $this->bookmarks ) >= static::MAX_BOOKMARKS ) { + _doing_it_wrong( + __METHOD__, + __( 'Too many bookmarks: cannot create any more.' ), + 'WP_VERSION' + ); + return false; + } + + $this->bookmarks[ $name ] = new WP_HTML_Span( $this->token_starts_at, $this->token_length ); + + return true; + } + + + /** + * Removes a bookmark that is no longer needed. + * + * Releasing a bookmark frees up the small + * performance overhead it requires. + * + * @param string $name Name of the bookmark to remove. + * @return bool Whether the bookmark already existed before removal. + */ + public function release_bookmark( $name ) { + if ( ! array_key_exists( $name, $this->bookmarks ) ) { + return false; + } + + unset( $this->bookmarks[ $name ] ); + + return true; + } + + /** + * Skips contents of PCDATA element. + * + * @since WP_VERSION + * + * @see https://www.w3.org/TR/xml/#sec-mixed-content + * + * @param string $tag_name The tag name which will close the PCDATA region. + * @return false|int Byte offset of the closing tag, or false if not found. + */ + private function skip_pcdata( $tag_name ) { + $xml = $this->xml; + $doc_length = strlen( $xml ); + $tag_length = strlen( $tag_name ); + + $at = $this->bytes_already_parsed; + while ( false !== $at && $at < $doc_length ) { + $at = strpos( $this->xml, 'tag_name_starts_at = $at; + + // Fail if there is no possible tag closer. + if ( false === $at ) { + return false; + } + + $at += 2 + $tag_length; + $at += strspn( $this->xml, " \t\f\r\n", $at ); + $this->bytes_already_parsed = $at; + + /* + * Ensure that the tag name terminates to avoid matching on + * substrings of a longer tag name. For example, the sequence + * "= strlen( $xml ) ) { + return false; + } + if ( '>' === $xml[ $at ] ) { + $this->bytes_already_parsed = $at + 1; + return true; + } + } + + return false; + } + + /** + * Returns the last error, if any. + * + * Various situations lead to parsing failure but this class will + * return `false` in all those cases. To determine why something + * failed it's possible to request the last error. This can be + * helpful to know to distinguish whether a given tag couldn't + * be found or if content in the document caused the processor + * to give up and abort processing. + * + * Example + * + * $processor = WP_XML_Tag_Processor::create_fragment( '' ); + * false === $processor->next_tag(); + * WP_XML_Tag_Processor::ERROR_SYNTAX === $processor->get_last_error(); + * + * @since WP_VERSION + * + * @see self::ERROR_UNSUPPORTED + * @see self::ERROR_EXCEEDED_MAX_BOOKMARKS + * + * @return string|null The last error, if one exists, otherwise null. + */ + public function get_last_error(): ?string { + return $this->last_error; + } + + /** + * Tag names declared as PCDATA elements. + * + * PCDATA elements are elements in which everything is treated as + * text, even syntax that may look like other elements, closers, + * processing instructions, etc. + * + * Example: + * + * + * + * This text contains syntax that seems + * like XML nodes: + * + * + * + * + * + * + * &<>"' + * + * But! It's all treated as text. + * + * + * + * @var array + */ + private $pcdata_elements = array(); + + /** + * Declares an element as PCDATA. + * + * PCDATA elements are elements in which everything is treated as + * text, even syntax that may look like other elements, closers, + * processing instructions, etc. + * + * For example: + * + * $processor = new WP_XML_Tag_Processor( + * << + * + * This text uses syntax that may seem + * like XML nodes: + * + * + * + * + * + * + * &<>"' + * + * But! It's all treated as text. + * + * + * XML + * ); + * + * $processor->declare_element_as_pcdata('my-pcdata'); + * $processor->next_tag('my-pcdata'); + * $processor->next_token(); + * + * // Returns everything inside the + * // element as text: + * $processor->get_modifiable_text(); + * + * @param string $element_name The name of the element to declare as PCDATA. + * @return void + */ + public function declare_element_as_pcdata( $element_name ) { + $this->pcdata_elements[ $element_name ] = true; + } + + /** + * Indicates if the currently matched tag is a PCDATA element. + * + * @since WP_VERSION + * + * @return bool Whether the currently matched tag is a PCDATA element. + */ + public function is_pcdata_element() { + return array_key_exists( $this->get_tag(), $this->pcdata_elements ); + } + + /** + * Parses the next tag. + * + * This will find and start parsing the next tag, including + * the opening `<`, the potential closer `/`, and the tag + * name. It does not parse the attributes or scan to the + * closing `>`; these are left for other methods. + * + * @since WP_VERSION + * + * @return bool Whether a tag was found before the end of the document. + */ + private function parse_next_tag() { + $this->after_tag(); + + $xml = $this->xml; + $doc_length = strlen( $xml ); + $was_at = $this->bytes_already_parsed; + $at = $was_at; + + while ( false !== $at && $at < $doc_length ) { + $at = strpos( $xml, '<', $at ); + + /* + * There may be no text nodes outside of elements. + * If this character sequence was encountered outside of + * the root element, it is a syntax error. WP_XML_Tag_Processor + * does not have that context – it is up to the API consumer, + * such as WP_Tag_Processor, to handle this scenario. + */ + if ( false === $at ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + $this->is_incomplete_text_node = true; + $this->text_starts_at = $was_at; + $this->text_length = $doc_length - $was_at; + return false; + } + + if ( $at > $was_at ) { + $this->parser_state = self::STATE_TEXT_NODE; + $this->token_starts_at = $was_at; + $this->token_length = $at - $was_at; + $this->text_starts_at = $was_at; + $this->text_length = $this->token_length; + $this->bytes_already_parsed = $at; + + return true; + } + + $this->token_starts_at = $at; + + if ( $at + 1 < $doc_length && '/' === $this->xml[ $at + 1 ] ) { + $this->is_closing_tag = true; + ++$at; + } else { + $this->is_closing_tag = false; + } + + if ( $at + 1 >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + return false; + } + + /* + * XML tag names are defined by the same `Name` grammar rule as attribute + * names. + * + * Reference: + * * https://www.w3.org/TR/xml/#NT-STag + * * https://www.w3.org/TR/xml/#NT-Name + */ + $tag_name_length = $this->parse_name( $at + 1 ); + if ( $tag_name_length > 0 ) { + ++$at; + $this->parser_state = self::STATE_MATCHED_TAG; + $this->tag_name_starts_at = $at; + $this->tag_name_length = $tag_name_length; + $this->token_length = $this->tag_name_length; + $this->bytes_already_parsed = $at + $this->tag_name_length; + + return true; + } + + /* + * Abort if no tag is found before the end of + * the document. There is nothing left to parse. + */ + if ( $at + 1 >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + + return false; + } + + /* + * `is_closing_tag && '!' === $xml[ $at + 1 ] ) { + /* + * ` sequence. + */ + --$closer_at; // Pre-increment inside condition below reduces risk of accidental infinite looping. + while ( ++$closer_at < $doc_length ) { + $closer_at = strpos( $xml, '--', $closer_at ); + if ( false === $closer_at || $closer_at + 2 === $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + return false; + } + + /* + * The string " -- " (double-hyphen) must not occur within comments + * See https://www.w3.org/TR/xml/#sec-comments + */ + if ( '>' !== $xml[ $closer_at + 2 ] ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'Invalid comment syntax encountered.' ), + 'WP_VERSION' + ); + return false; + } + + $this->parser_state = self::STATE_COMMENT; + $this->token_length = $closer_at + 3 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 4; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 3; + return true; + } + } + + /* + * Identify CDATA sections. + * + * Within a CDATA section, everything until the ]]> string is treated + * as data, not markup. Left angle brackets and ampersands may occur in + * their literal form; they need not (and cannot) be escaped using "<" + * and "&". CDATA sections cannot nest. + * + * See https://www.w3.org/TR/xml11.xml/#sec-cdata-sect + */ + if ( + ! $this->is_closing_tag && + $doc_length > $this->token_starts_at + 8 && + '[' === $xml[ $this->token_starts_at + 2 ] && + 'C' === $xml[ $this->token_starts_at + 3 ] && + 'D' === $xml[ $this->token_starts_at + 4 ] && + 'A' === $xml[ $this->token_starts_at + 5 ] && + 'T' === $xml[ $this->token_starts_at + 6 ] && + 'A' === $xml[ $this->token_starts_at + 7 ] && + '[' === $xml[ $this->token_starts_at + 8 ] + ) { + $closer_at = strpos( $xml, ']]>', $at + 1 ); + if ( false === $closer_at ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + + return false; + } + + $this->parser_state = self::STATE_CDATA_NODE; + $this->token_length = $closer_at + 1 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 9; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 3; + return true; + } + + /* + * Anything else here is either unsupported at this point or invalid + * syntax. See the class-level @TODO annotations for more information. + */ + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + + return false; + } + + /* + * An `had_previous_chunks && + ! $this->is_closing_tag && + '?' === $xml[ $at + 1 ] && + 'x' === $xml[ $at + 2 ] && + 'm' === $xml[ $at + 3 ] && + 'l' === $xml[ $at + 4 ] + ) { + // Setting the parser state early for the get_attribute() calls later in this + // branch. + $this->parser_state = self::STATE_XML_DECLARATION; + + $at += 5; + + // Skip whitespace. + $at += strspn( $this->xml, " \t\f\r\n", $at ); + + $this->bytes_already_parsed = $at; + + /* + * Reuse parse_next_attribute() to parse the XML declaration attributes. + * Technically, only "version", "encoding", and "standalone" are accepted + * and, unlike regular tag attributes, their values can contain any character + * other than the opening quote. However, the "<" and "&" characters are very + * unlikely to be encountered and cause trouble, so this code path liberally + * does not provide a dedicated parsing logic. + */ + while ( false !== $this->parse_next_attribute() ) { + $this->skip_whitespace(); + // Parse until the XML declaration closer. + if ( '?' === $xml[ $this->bytes_already_parsed ] ) { + break; + } + } + + if ( null !== $this->last_error ) { + return false; + } + + foreach ( $this->attributes as $name => $attribute ) { + if ( 'version' !== $name && 'encoding' !== $name && 'standalone' !== $name ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'Invalid attribute found in XML declaration.' ), + 'WP_VERSION' + ); + return false; + } + } + + if ( '1.0' !== $this->get_attribute( 'version' ) ) { + $this->last_error = self::ERROR_UNSUPPORTED; + _doing_it_wrong( + __METHOD__, + __( 'Unsupported XML version declared' ), + 'WP_VERSION' + ); + return false; + } + + /** + * Standalone XML documents have no external dependencies, + * including predefined entities like ` ` and `©`. + * + * See https://www.w3.org/TR/xml/#sec-predefined-ent. + */ + if ( null !== $this->get_attribute( 'encoding' ) + && 'UTF-8' !== strtoupper( $this->get_attribute( 'encoding' ) ) + ) { + $this->last_error = self::ERROR_UNSUPPORTED; + _doing_it_wrong( + __METHOD__, + __( 'Unsupported XML encoding declared, only UTF-8 is supported.' ), + 'WP_VERSION' + ); + return false; + } + if ( null !== $this->get_attribute( 'standalone' ) + && 'YES' !== strtoupper( $this->get_attribute( 'standalone' ) ) + ) { + $this->last_error = self::ERROR_UNSUPPORTED; + _doing_it_wrong( + __METHOD__, + __( 'Standalone XML documents are not supported.' ), + 'WP_VERSION' + ); + return false; + } + + $at = $this->bytes_already_parsed; + + // Skip whitespace. + $at += strspn( $this->xml, " \t\f\r\n", $at ); + + // Consume the closer. + if ( ! ( + $at + 2 <= $doc_length && + '?' === $xml[ $at ] && + '>' === $xml[ $at + 1 ] + ) ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'XML declaration closer not found.' ), + 'WP_VERSION' + ); + return false; + } + + $this->token_length = $at + 2 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 2; + $this->text_length = $at - $this->text_starts_at; + $this->bytes_already_parsed = $at + 2; + $this->parser_state = self::STATE_XML_DECLARATION; + + return true; + } + + /* + * `is_closing_tag && + '?' === $xml[ $at + 1 ] + ) { + if ( $at + 4 >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + + return false; + } + + if ( ! ( + ( 'x' === $xml[ $at + 2 ] || 'X' === $xml[ $at + 2 ] ) && + ( 'm' === $xml[ $at + 3 ] || 'M' === $xml[ $at + 3 ] ) && + ( 'l' === $xml[ $at + 4 ] || 'L' === $xml[ $at + 4 ] ) + ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Invalid processing instruction target.' ), + 'WP_VERSION' + ); + return false; + } + + $at += 5; + + // Skip whitespace. + $this->skip_whitespace(); + + /* + * Find the closer. + * + * We could, at this point, only consume the bytes allowed by the specification, that is: + * + * [2] Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] // any Unicode character, excluding the surrogate blocks, FFFE, and FFFF. + * + * However, that would require running a slow regular-expression engine for, seemingly, + * little benefit. For now, we are going to pretend that all bytes are allowed until the + * closing ?> is found. Some failures may pass unnoticed. That may not be a problem in practice, + * but if it is then this code path will require a stricter implementation. + */ + $closer_at = strpos( $xml, '?>', $at ); + if ( false === $closer_at ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + + return false; + } + + $this->parser_state = self::STATE_PI_NODE; + $this->token_length = $closer_at + 5 - $this->token_starts_at; + $this->text_starts_at = $this->token_starts_at + 5; + $this->text_length = $closer_at - $this->text_starts_at; + $this->bytes_already_parsed = $closer_at + 2; + + return true; + } + + ++$at; + } + + return false; + } + + /** + * Parses the next attribute. + * + * @since WP_VERSION + * + * @return bool Whether an attribute was found before the end of the document. + */ + private function parse_next_attribute() { + // Skip whitespace and slashes. + $this->bytes_already_parsed += strspn( $this->xml, " \t\f\r\n/", $this->bytes_already_parsed ); + if ( $this->bytes_already_parsed >= strlen( $this->xml ) ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + return false; + } + + // No more attributes to parse. + if ( '>' === $this->xml[ $this->bytes_already_parsed ] ) { + return false; + } + + $attribute_start = $this->bytes_already_parsed; + $attribute_name_length = $this->parse_name( $this->bytes_already_parsed ); + if ( 0 === $attribute_name_length ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'Invalid attribute name encountered.' ), + 'WP_VERSION' + ); + } + $this->bytes_already_parsed += $attribute_name_length; + $attribute_name = substr( $this->xml, $attribute_start, $attribute_name_length ); + $this->skip_whitespace(); + + // Parse attribute value. + ++$this->bytes_already_parsed; + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= strlen( $this->xml ) ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + return false; + } + switch ( $this->xml[ $this->bytes_already_parsed ] ) { + case "'": + case '"': + $quote = $this->xml[ $this->bytes_already_parsed ]; + $value_start = $this->bytes_already_parsed + 1; + /** + * XML attributes cannot contain the characters "<" or "&". + * + * This only checks for "<" because it's reasonably fast. + * Ampersands are actually allowed when used as the start + * of an entity reference, but enforcing that would require + * an expensive and complex check. It doesn't seem to be + * worth it. + * + * @TODO: Discuss enforcing or abandoning the ampersand rule + * and document the rationale. + */ + $value_length = strcspn( $this->xml, "<$quote", $value_start ); + $attribute_end = $value_start + $value_length + 1; + + if ( $attribute_end - 1 >= strlen( $this->xml ) ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + + return false; + } + + if ( $this->xml[ $attribute_end - 1 ] !== $quote ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'A disallowed character encountered in an attribute value (either < or &).' ), + 'WP_VERSION' + ); + } + $this->bytes_already_parsed = $attribute_end; + break; + + default: + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'Unquoted attribute value encountered.' ), + 'WP_VERSION' + ); + return false; + } + + if ( $attribute_end >= strlen( $this->xml ) ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + return false; + } + + if ( $this->is_closing_tag ) { + return true; + } + + if ( array_key_exists( $attribute_name, $this->attributes ) ) { + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'Duplicate attribute found in an XML tag.' ), + 'WP_VERSION' + ); + return false; + } + + $this->attributes[ $attribute_name ] = new WP_HTML_Attribute_Token( + $attribute_name, + $value_start, + $value_length, + $attribute_start, + $attribute_end - $attribute_start, + false + ); + + return true; + } + + /** + * Move the internal cursor past any immediate successive whitespace. + * + * @since WP_VERSION + */ + private function skip_whitespace() { + $this->bytes_already_parsed += strspn( $this->xml, " \t\f\r\n", $this->bytes_already_parsed ); + } + + // Describes the first character of the attribute name: + // NameStartChar ::= ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x2FF] | [#x370-#x37D] | [#x37F-#x1FFF] | [#x200C-#x200D] | [#x2070-#x218F] | [#x2C00-#x2FEF] | [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] | [#x10000-#xEFFFF] + // See https://www.w3.org/TR/xml/#NT-Name + const NAME_START_CHAR_PATTERN = ':a-z_A-Z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}'; + const NAME_CHAR_PATTERN = '\-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}:a-z_A-Z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}'; + private function parse_name( $offset ) { + if ( 1 !== preg_match( + '~[' . self::NAME_START_CHAR_PATTERN . ']~Ssu', + $this->xml[ $offset ], + $matches + ) ) { + return 0; + } + + $name_length = 1; + + // Consume the rest of the name + preg_match( + '~\G([' . self::NAME_CHAR_PATTERN . ']+)~Ssu', + $this->xml, + $matches, + 0, + $offset + 1 + ); + + if ( is_array( $matches ) && count( $matches ) > 0 ) { + $name_length += strlen( $matches[0] ); + } + + return $name_length; + } + + /** + * Applies attribute updates and cleans up once a tag is fully parsed. + * + * @since WP_VERSION + */ + private function after_tag() { + /* + * Purge updates if there are too many. The actual count isn't + * scientific, but a few values from 100 to a few thousand were + * tests to find a practically-useful limit. + * + * If the update queue grows too big, then the Tag Processor + * will spend more time iterating through them and lose the + * efficiency gains of deferring applying them. + */ + if ( 1000 < count( $this->lexical_updates ) ) { + $this->get_updated_xml(); + } + + foreach ( $this->lexical_updates as $name => $update ) { + /* + * Any updates appearing after the cursor should be applied + * before proceeding, otherwise they may be overlooked. + */ + if ( $update->start >= $this->bytes_already_parsed ) { + $this->get_updated_xml(); + break; + } + + if ( is_int( $name ) ) { + continue; + } + + $this->lexical_updates[] = $update; + unset( $this->lexical_updates[ $name ] ); + } + + $this->is_incomplete_text_node = false; + $this->token_starts_at = null; + $this->token_length = null; + $this->tag_name_starts_at = null; + $this->tag_name_length = null; + $this->text_starts_at = 0; + $this->text_length = 0; + $this->is_closing_tag = null; + $this->attributes = array(); + } + + protected function reset_state() { + $this->xml = ''; + $this->last_query = null; + $this->sought_tag_name = null; + $this->sought_match_offset = 0; + $this->stop_on_tag_closers = false; + $this->parser_state = self::STATE_READY; + $this->is_incomplete_text_node = false; + $this->bytes_already_parsed = 0; + $this->token_starts_at = null; + $this->token_length = null; + $this->tag_name_starts_at = null; + $this->tag_name_length = null; + $this->text_starts_at = 0; + $this->text_length = 0; + $this->is_closing_tag = null; + $this->last_error = null; + $this->attributes = array(); + $this->bookmarks = array(); + $this->lexical_updates = array(); + $this->seek_count = 0; + $this->had_previous_chunks = false; + } + + /** + * Applies attribute updates to XML document. + * + * @since WP_VERSION + * + * @param int $shift_this_point Accumulate and return shift for this position. + * @return int How many bytes the given pointer moved in response to the updates. + */ + private function apply_attributes_updates( $shift_this_point = 0 ) { + if ( ! count( $this->lexical_updates ) ) { + return 0; + } + + $accumulated_shift_for_given_point = 0; + + /* + * Attribute updates can be enqueued in any order but updates + * to the document must occur in lexical order; that is, each + * replacement must be made before all others which follow it + * at later string indices in the input document. + * + * Sorting avoid making out-of-order replacements which + * can lead to mangled output, partially-duplicated + * attributes, and overwritten attributes. + */ + usort( $this->lexical_updates, array( self::class, 'sort_start_ascending' ) ); + + $bytes_already_copied = 0; + $output_buffer = ''; + foreach ( $this->lexical_updates as $diff ) { + $shift = strlen( $diff->text ) - $diff->length; + + // Adjust the cursor position by however much an update affects it. + if ( $diff->start < $this->bytes_already_parsed ) { + $this->bytes_already_parsed += $shift; + } + + // Accumulate shift of the given pointer within this function call. + if ( $diff->start <= $shift_this_point ) { + $accumulated_shift_for_given_point += $shift; + } + + $output_buffer .= substr( $this->xml, $bytes_already_copied, $diff->start - $bytes_already_copied ); + $output_buffer .= $diff->text; + $bytes_already_copied = $diff->start + $diff->length; + } + + $this->xml = $output_buffer . substr( $this->xml, $bytes_already_copied ); + + /* + * Adjust bookmark locations to account for how the text + * replacements adjust offsets in the input document. + */ + foreach ( $this->bookmarks as $bookmark_name => $bookmark ) { + $bookmark_end = $bookmark->start + $bookmark->length; + + /* + * Each lexical update which appears before the bookmark's endpoints + * might shift the offsets for those endpoints. Loop through each change + * and accumulate the total shift for each bookmark, then apply that + * shift after tallying the full delta. + */ + $head_delta = 0; + $tail_delta = 0; + + foreach ( $this->lexical_updates as $diff ) { + $diff_end = $diff->start + $diff->length; + + if ( $bookmark->start < $diff->start && $bookmark_end < $diff->start ) { + break; + } + + if ( $bookmark->start >= $diff->start && $bookmark_end < $diff_end ) { + $this->release_bookmark( $bookmark_name ); + continue 2; + } + + $delta = strlen( $diff->text ) - $diff->length; + + if ( $bookmark->start >= $diff->start ) { + $head_delta += $delta; + } + + if ( $bookmark_end >= $diff_end ) { + $tail_delta += $delta; + } + } + + $bookmark->start += $head_delta; + $bookmark->length += $tail_delta - $head_delta; + } + + $this->lexical_updates = array(); + + return $accumulated_shift_for_given_point; + } + + /** + * Checks whether a bookmark with the given name exists. + * + * @since WP_VERSION + * + * @param string $bookmark_name Name to identify a bookmark that potentially exists. + * @return bool Whether that bookmark exists. + */ + public function has_bookmark( $bookmark_name ) { + return array_key_exists( $bookmark_name, $this->bookmarks ); + } + + public function get_processed_xml() { + // Flush updates + $this->get_updated_xml(); + return substr( $this->xml, 0, $this->bytes_already_parsed ); + } + + public function get_unprocessed_xml() { + // Flush updates + $this->get_updated_xml(); + return substr( $this->xml, $this->bytes_already_parsed ); + } + + + /** + * Move the internal cursor in the Tag Processor to a given bookmark's location. + * + * In order to prevent accidental infinite loops, there's a + * maximum limit on the number of times seek() can be called. + * + * @since WP_VERSION + * + * @param string $bookmark_name Jump to the place in the document identified by this bookmark name. + * @return bool Whether the internal cursor was successfully moved to the bookmark's location. + */ + public function seek( $bookmark_name ) { + if ( ! array_key_exists( $bookmark_name, $this->bookmarks ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Unknown bookmark name.' ), + 'WP_VERSION' + ); + return false; + } + + if ( ++$this->seek_count > static::MAX_SEEK_OPS ) { + _doing_it_wrong( + __METHOD__, + __( 'Too many calls to seek() - this can lead to performance issues.' ), + 'WP_VERSION' + ); + return false; + } + + // Flush out any pending updates to the document. + $this->get_updated_xml(); + + // Point this tag processor before the sought tag opener and consume it. + $this->bytes_already_parsed = $this->bookmarks[ $bookmark_name ]->start; + $this->parser_state = self::STATE_READY; + return $this->base_class_next_token(); + } + + /** + * Compare two WP_HTML_Text_Replacement objects. + * + * @since WP_VERSION + * + * @param WP_HTML_Text_Replacement $a First attribute update. + * @param WP_HTML_Text_Replacement $b Second attribute update. + * @return int Comparison value for string order. + */ + private static function sort_start_ascending( $a, $b ) { + $by_start = $a->start - $b->start; + if ( 0 !== $by_start ) { + return $by_start; + } + + $by_text = isset( $a->text, $b->text ) ? strcmp( $a->text, $b->text ) : 0; + if ( 0 !== $by_text ) { + return $by_text; + } + + /* + * This code should be unreachable, because it implies the two replacements + * start at the same location and contain the same text. + */ + return $a->length - $b->length; + } + + /** + * Return the enqueued value for a given attribute, if one exists. + * + * Enqueued updates can take different data types: + * - If an update is enqueued and is boolean, the return will be `true` + * - If an update is otherwise enqueued, the return will be the string value of that update. + * - If an attribute is enqueued to be removed, the return will be `null` to indicate that. + * - If no updates are enqueued, the return will be `false` to differentiate from "removed." + * + * @since WP_VERSION + * + * @param string $comparable_name The attribute name in its comparable form. + * @return string|boolean|null Value of enqueued update if present, otherwise false. + */ + private function get_enqueued_attribute_value( $comparable_name ) { + if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { + return false; + } + + if ( ! isset( $this->lexical_updates[ $comparable_name ] ) ) { + return false; + } + + $enqueued_text = $this->lexical_updates[ $comparable_name ]->text; + + // Removed attributes erase the entire span. + if ( '' === $enqueued_text ) { + return null; + } + + /* + * Boolean attribute updates are just the attribute name without a corresponding value. + * + * This value might differ from the given comparable name in that there could be leading + * or trailing whitespace, and that the casing follows the name given in `set_attribute`. + * + * Example: + * + * $p->set_attribute( 'data-TEST-id', 'update' ); + * 'update' === $p->get_enqueued_attribute_value( 'data-test-id' ); + * + * Detect this difference based on the absence of the `=`, which _must_ exist in any + * attribute containing a value, e.g. ``. + * ¹ ² + * 1. Attribute with a string value. + * 2. Boolean attribute whose value is `true`. + */ + $equals_at = strpos( $enqueued_text, '=' ); + if ( false === $equals_at ) { + return true; + } + + /* + * Finally, a normal update's value will appear after the `=` and + * be double-quoted, as performed incidentally by `set_attribute`. + * + * e.g. `type="text"` + * ¹² ³ + * 1. Equals is here. + * 2. Double-quoting starts one after the equals sign. + * 3. Double-quoting ends at the last character in the update. + */ + $enqueued_value = substr( $enqueued_text, $equals_at + 2, -1 ); + /* + * We're deliberately not decoding entities in attribute values: + * + * Attribute values must not contain direct or indirect entity references to external entities. + * + * See https://www.w3.org/TR/xml/#sec-starttags. + */ + return $enqueued_value; + } + + /** + * Returns the value of a requested attribute from a matched tag opener if that attribute exists. + * + * Example: + * + * $p = new WP_XML_Tag_Processor( 'Test' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute( 'data-test-id' ) === '14'; + * $p->get_attribute( 'enabled' ) === true; + * $p->get_attribute( 'aria-label' ) === null; + * + * $p->next_tag() === false; + * $p->get_attribute( 'class' ) === null; + * + * @since WP_VERSION + * + * @param string $name Name of attribute whose value is requested. + * @return string|true|null Value of attribute or `null` if not available. Boolean attributes return `true`. + */ + public function get_attribute( $name ) { + if ( + self::STATE_MATCHED_TAG !== $this->parser_state && + self::STATE_XML_DECLARATION !== $this->parser_state + ) { + return null; + } + + // Return any enqueued attribute value updates if they exist. + $enqueued_value = $this->get_enqueued_attribute_value( $name ); + if ( false !== $enqueued_value ) { + return $enqueued_value; + } + + if ( ! isset( $this->attributes[ $name ] ) ) { + return null; + } + + $attribute = $this->attributes[ $name ]; + $raw_value = substr( $this->xml, $attribute->value_starts_at, $attribute->value_length ); + + $decoded = WP_XML_Decoder::decode( $raw_value ); + if ( ! isset( $decoded ) ) { + /** + * If the attribute contained an invalid value, it's + * a fatal error. + * + * @see WP_XML_Decoder::decode() + */ + $this->last_error = self::ERROR_SYNTAX; + _doing_it_wrong( + __METHOD__, + __( 'Invalid attribute value encountered.' ), + 'WP_VERSION' + ); + return false; + } + + return $decoded; + } + + /** + * Gets names of all attributes matching a given prefix in the current tag. + * + * Note that matching is case-sensitive. This is in accordance with the spec. + * + * Example: + * + * $p = new WP_XML_Tag_Processor( 'Test' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-ENABLED' ); + * $p->get_attribute_names_with_prefix( 'DATA-' ) === array( 'DATA-test-id' ); + * $p->get_attribute_names_with_prefix( 'DAta-' ) === array(); + * + * @since WP_VERSION + * + * @param string $prefix Prefix of requested attribute names. + * @return array|null List of attribute names, or `null` when no tag opener is matched. + */ + public function get_attribute_names_with_prefix( $prefix ) { + if ( + self::STATE_MATCHED_TAG !== $this->parser_state || + $this->is_closing_tag + ) { + return null; + } + + $matches = array(); + foreach ( array_keys( $this->attributes ) as $attr_name ) { + if ( str_starts_with( $attr_name, $prefix ) ) { + $matches[] = $attr_name; + } + } + return $matches; + } + + /** + * Returns the uppercase name of the matched tag. + * + * Example: + * + * $p = new WP_XML_Tag_Processor( 'Test' ); + * $p->next_tag() === true; + * $p->get_tag() === 'DIV'; + * + * $p->next_tag() === false; + * $p->get_tag() === null; + * + * @since WP_VERSION + * + * @return string|null Name of currently matched tag in input XML, or `null` if none found. + */ + public function get_tag() { + if ( null === $this->tag_name_starts_at ) { + return null; + } + + $tag_name = substr( $this->xml, $this->tag_name_starts_at, $this->tag_name_length ); + + if ( self::STATE_MATCHED_TAG === $this->parser_state ) { + return $tag_name; + } + + return null; + } + + /** + * Indicates if the currently matched tag is an empty element tag. + * + * XML tags ending with a solidus ("/") are parsed as empty elements. They have no + * content and no matching closer is expected. + + * @since WP_VERSION + * + * @return bool Whether the currently matched tag is an empty element tag. + */ + public function is_empty_element() { + if ( self::STATE_MATCHED_TAG !== $this->parser_state ) { + return false; + } + + /* + * An empty element tag is defined by the solidus at the _end_ of the tag, not the beginning. + * + * Example: + * + *
+ * ^ this appears one character before the end of the closing ">". + */ + return '/' === $this->xml[ $this->token_starts_at + $this->token_length - 2 ]; + } + + /** + * Indicates if the current tag token is a tag closer. + * + * Example: + * + * $p = new WP_XML_Tag_Processor( '' ); + * $p->next_tag( array( 'tag_name' => 'wp:content', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === false; + * + * $p->next_tag( array( 'tag_name' => 'wp:content', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === true; + * + * @since WP_VERSION + * + * @return bool Whether the current tag is a tag closer. + */ + public function is_tag_closer() { + return ( + self::STATE_MATCHED_TAG === $this->parser_state && + $this->is_closing_tag + ); + } + + /** + * Indicates the kind of matched token, if any. + * + * This differs from `get_token_name()` in that it always + * returns a static string indicating the type, whereas + * `get_token_name()` may return values derived from the + * token itself, such as a tag name or processing + * instruction tag. + * + * Possible values: + * - `#tag` when matched on a tag. + * - `#text` when matched on a text node. + * - `#cdata-section` when matched on a CDATA node. + * - `#comment` when matched on a comment. + * - `#presumptuous-tag` when matched on an empty tag closer. + * + * @since WP_VERSION + * + * @return string|null What kind of token is matched, or null. + */ + public function get_token_type() { + switch ( $this->parser_state ) { + case self::STATE_MATCHED_TAG: + return '#tag'; + + default: + return $this->get_token_name(); + } + } + + /** + * Returns the node name represented by the token. + * + * This matches the DOM API value `nodeName`. Some values + * are static, such as `#text` for a text node, while others + * are dynamically generated from the token itself. + * + * Dynamic names: + * - Uppercase tag name for tag matches. + * + * Note that if the Tag Processor is not matched on a token + * then this function will return `null`, either because it + * hasn't yet found a token or because it reached the end + * of the document without matching a token. + * + * @since WP_VERSION + * + * @return string|null Name of the matched token. + */ + public function get_token_name() { + switch ( $this->parser_state ) { + case self::STATE_MATCHED_TAG: + return $this->get_tag(); + + case self::STATE_TEXT_NODE: + return '#text'; + + case self::STATE_CDATA_NODE: + return '#cdata-section'; + + case self::STATE_XML_DECLARATION: + return '#xml-declaration'; + + case self::STATE_PI_NODE: + return '#processing-instructions'; + + case self::STATE_COMMENT: + return '#comment'; + } + } + + /** + * Returns the modifiable text for a matched token, or an empty string. + * + * Modifiable text is text content that may be read and changed without + * changing the XML structure of the document around it. This includes + * the contents of `#text` nodes in the XML as well as the inner + * contents of XML comments, Processing Instructions, and others, even + * though these nodes aren't part of a parsed DOM tree. They also contain + * the contents of SCRIPT and STYLE tags, of TEXTAREA tags, and of any + * other section in an XML document which cannot contain XML markup (DATA). + * + * If a token has no modifiable text then an empty string is returned to + * avoid needless crashing or type errors. An empty string does not mean + * that a token has modifiable text, and a token with modifiable text may + * have an empty string (e.g. a comment with no contents). + * + * @since WP_VERSION + * + * @return string + */ + public function get_modifiable_text() { + if ( null === $this->text_starts_at ) { + return ''; + } + + $text = substr( $this->xml, $this->text_starts_at, $this->text_length ); + + /* + * > the XML processor must behave as if it normalized all line breaks in external parsed + * > entities (including the document entity) on input, before parsing, by translating both + * > the two-character sequence #xD #xA and any #xD that is not followed by #xA to a single + * > #xA character. + * + * See https://www.w3.org/TR/xml/#sec-line-ends + */ + $text = str_replace( array( "\r\n", "\r" ), "\n", $text ); + + // Comment data, CDATA sections, and PCData tags contents are not decoded any further. + if ( + self::STATE_CDATA_NODE === $this->parser_state || + self::STATE_COMMENT === $this->parser_state || + $this->is_pcdata_element() + ) { + return $text; + } + + $decoded = WP_XML_Decoder::decode( $text ); + if ( ! isset( $decoded ) ) { + /** + * If the attribute contained an invalid value, it's + * a fatal error. + * + * @see WP_XML_Decoder::decode() + */ + + $this->last_error = self::ERROR_SYNTAX; + var_dump( $text ); + _doing_it_wrong( + __METHOD__, + __( 'Invalid text content encountered.' ), + 'WP_VERSION' + ); + return false; + } + return $decoded; + } + + public function set_modifiable_text( $new_value ) { + switch ( $this->parser_state ) { + case self::STATE_TEXT_NODE: + case self::STATE_COMMENT: + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + // @TODO This is naive, let's rethink this. + htmlspecialchars( $new_value, ENT_XML1, 'UTF-8' ) + ); + return true; + + case self::STATE_CDATA_NODE: + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + // @TODO This is naive, let's rethink this. + str_replace( ']]>', ']]>', $new_value ) + ); + return true; + default: + _doing_it_wrong( + __METHOD__, + __( 'Cannot set text content on a non-text node.' ), + 'WP_VERSION' + ); + return false; + } + } + + /** + * Updates or creates a new attribute on the currently matched tag with the passed value. + * + * For boolean attributes special handling is provided: + * - When `true` is passed as the value, then only the attribute name is added to the tag. + * - When `false` is passed, the attribute gets removed if it existed before. + * + * For string attributes, the value is escaped using the `esc_attr` function. + * + * @since WP_VERSION + * + * @param string $name The attribute name to target. + * @param string|bool $value The new attribute value. + * @return bool Whether an attribute value was set. + */ + public function set_attribute( $name, $value ) { + if ( ! is_string( $value ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Non-string attribute values cannot be passed to set_attribute().' ), + 'WP_VERSION' + ); + return false; + } + if ( + self::STATE_MATCHED_TAG !== $this->parser_state || + $this->is_closing_tag + ) { + return false; + } + + $value = htmlspecialchars( $value, ENT_XML1, 'UTF-8' ); + $updated_attribute = "{$name}=\"{$value}\""; + + /* + * > An attribute name must not appear more than once + * > in the same start-tag or empty-element tag. + * - XML 1.0 spec + * + * @see https://www.w3.org/TR/xml/#sec-starttags + */ + if ( isset( $this->attributes[ $name ] ) ) { + /* + * Update an existing attribute. + * + * Example – set attribute id to "new" in : + * + * + * ^-------------^ + * start end + * replacement: `id="new"` + * + * Result: + */ + $existing_attribute = $this->attributes[ $name ]; + $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( + $existing_attribute->start, + $existing_attribute->length, + $updated_attribute + ); + } else { + /* + * Create a new attribute at the tag's name end. + * + * Example – add attribute id="new" to : + * + * + * ^ + * start and end + * replacement: ` id="new"` + * + * Result: + */ + $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( + $this->tag_name_starts_at + $this->tag_name_length, + 0, + ' ' . $updated_attribute + ); + } + + return true; + } + + /** + * Remove an attribute from the currently-matched tag. + * + * @since WP_VERSION + * + * @param string $name The attribute name to remove. + * @return bool Whether an attribute was removed. + */ + public function remove_attribute( $name ) { + if ( + self::STATE_MATCHED_TAG !== $this->parser_state || + $this->is_closing_tag + ) { + return false; + } + + /* + * If updating an attribute that didn't exist in the input + * document, then remove the enqueued update and move on. + * + * For example, this might occur when calling `remove_attribute()` + * after calling `set_attribute()` for the same attribute + * and when that attribute wasn't originally present. + */ + if ( ! isset( $this->attributes[ $name ] ) ) { + if ( isset( $this->lexical_updates[ $name ] ) ) { + unset( $this->lexical_updates[ $name ] ); + } + return false; + } + + /* + * Removes an existing tag attribute. + * + * Example – remove the attribute id from : + * + * ^-------------^ + * start end + * replacement: `` + * + * Result: + */ + $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( + $this->attributes[ $name ]->start, + $this->attributes[ $name ]->length, + '' + ); + + return true; + } + + /** + * Returns the string representation of the XML Tag Processor. + * + * @since WP_VERSION + * + * @see WP_XML_Tag_Processor::get_updated_xml() + * + * @return string The processed XML. + */ + public function __toString() { + return $this->get_updated_xml(); + } + + /** + * Returns the string representation of the XML Tag Processor. + * + * @since WP_VERSION + * + * @return string The processed XML. + */ + public function get_updated_xml() { + $requires_no_updating = 0 === count( $this->lexical_updates ); + + /* + * When there is nothing more to update and nothing has already been + * updated, return the original document and avoid a string copy. + */ + if ( $requires_no_updating ) { + return $this->xml; + } + + /* + * Keep track of the position right before the current tag. This will + * be necessary for reparsing the current tag after updating the XML. + */ + $before_current_tag = $this->token_starts_at; + + /* + * 1. Apply the enqueued edits and update all the pointers to reflect those changes. + */ + $before_current_tag += $this->apply_attributes_updates( $before_current_tag ); + + /* + * 2. Rewind to before the current tag and reparse to get updated attributes. + * + * At this point the internal cursor points to the end of the tag name. + * Rewind before the tag name starts so that it's as if the cursor didn't + * move; a call to `next_tag()` will reparse the recently-updated attributes + * and additional calls to modify the attributes will apply at this same + * location, but in order to avoid issues with subclasses that might add + * behaviors to `next_tag()`, the internal methods should be called here + * instead. + * + * It's important to note that in this specific place there will be no change + * because the processor was already at a tag when this was called and it's + * rewinding only to the beginning of this very tag before reprocessing it + * and its attributes. + * + *

Previous XMLMore XML

+ * ↑ │ back up by the length of the tag name plus the opening < + * └←─┘ back up by strlen("em") + 1 ==> 3 + */ + $this->bytes_already_parsed = $before_current_tag; + $this->base_class_next_token(); + + return $this->xml; + } + + /** + * Parses tag query input into internal search criteria. + * + * @since WP_VERSION + * + * @param array|string|null $query { + * Optional. Which tag name to find, having which class, etc. Default is to find any tag. + * + * @type string|null $tag_name Which tag to find, or `null` for "any tag." + * @type int|null $match_offset Find the Nth tag matching all search criteria. + * 1 for "first" tag, 3 for "third," etc. + * Defaults to first tag. + * @type string $tag_closers "visit" or "skip": whether to stop on tag closers, e.g.
. + * } + */ + private function parse_query( $query ) { + if ( null !== $query && $query === $this->last_query ) { + return; + } + + $this->last_query = $query; + $this->sought_tag_name = null; + $this->sought_match_offset = 1; + $this->stop_on_tag_closers = false; + + // A single string value means "find the tag of this name". + if ( is_string( $query ) ) { + $this->sought_tag_name = $query; + return; + } + + // An empty query parameter applies no restrictions on the search. + if ( null === $query ) { + return; + } + + // If not using the string interface, an associative array is required. + if ( ! is_array( $query ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The query argument must be an array or a tag name.' ), + 'WP_VERSION' + ); + return; + } + + if ( isset( $query['tag_name'] ) && is_string( $query['tag_name'] ) ) { + $this->sought_tag_name = $query['tag_name']; + } + + if ( isset( $query['match_offset'] ) && is_int( $query['match_offset'] ) && 0 < $query['match_offset'] ) { + $this->sought_match_offset = $query['match_offset']; + } + + if ( isset( $query['tag_closers'] ) ) { + $this->stop_on_tag_closers = 'visit' === $query['tag_closers']; + } + } + + + /** + * Checks whether a given tag and its attributes match the search criteria. + * + * @since WP_VERSION + * + * @return bool Whether the given tag and its attribute match the search criteria. + */ + private function matches() { + if ( $this->is_closing_tag && ! $this->stop_on_tag_closers ) { + return false; + } + + // Does the tag name match the requested tag name in a case-insensitive manner? + if ( null !== $this->sought_tag_name ) { + /* + * String (byte) length lookup is fast. If they aren't the + * same length then they can't be the same string values. + */ + if ( strlen( $this->sought_tag_name ) !== $this->tag_name_length ) { + return false; + } + + /* + * Check each character to determine if they are the same. + */ + for ( $i = 0; $i < $this->tag_name_length; $i++ ) { + if ( $this->xml[ $this->tag_name_starts_at + $i ] !== $this->sought_tag_name[ $i ] ) { + return false; + } + } + } + + return true; + } + + /** + * Parser Ready State. + * + * Indicates that the parser is ready to run and waiting for a state transition. + * It may not have started yet, or it may have just finished parsing a token and + * is ready to find the next one. + * + * @since WP_VERSION + * + * @access private + */ + const STATE_READY = 'STATE_READY'; + + /** + * Parser Complete State. + * + * Indicates that the parser has reached the end of the document and there is + * nothing left to scan. It finished parsing the last token completely. + * + * @since WP_VERSION + * + * @access private + */ + const STATE_COMPLETE = 'STATE_COMPLETE'; + + /** + * Parser Incomplete Input State. + * + * Indicates that the parser has reached the end of the document before finishing + * a token. It started parsing a token but there is a possibility that the input + * XML document was truncated in the middle of a token. + * + * The parser is reset at the start of the incomplete token and has paused. There + * is nothing more than can be scanned unless provided a more complete document. + * + * @since WP_VERSION + * + * @access private + */ + const STATE_INCOMPLETE_INPUT = 'STATE_INCOMPLETE_INPUT'; + + /** + * Parser Invalid Input State. + * + * Indicates that the parsed xml document contains malformed input and cannot be parsed. + * + * @since WP_VERSION + * + * @access private + */ + const STATE_INVALID_DOCUMENT = 'STATE_INVALID_DOCUMENT'; + + /** + * Parser Matched Tag State. + * + * Indicates that the parser has found an XML tag and it's possible to get + * the tag name and read or modify its attributes (if it's not a closing tag). + * + * @since WP_VERSION + * + * @access private + */ + const STATE_MATCHED_TAG = 'STATE_MATCHED_TAG'; + + /** + * Parser Text Node State. + * + * Indicates that the parser has found a text node and it's possible + * to read and modify that text. + * + * @since WP_VERSION + * + * @access private + */ + const STATE_TEXT_NODE = 'STATE_TEXT_NODE'; + + /** + * Parser CDATA Node State. + * + * Indicates that the parser has found a CDATA node and it's possible + * to read and modify its modifiable text. Note that in XML there are + * no CDATA nodes outside of foreign content (SVG and MathML). Outside + * of foreign content, they are treated as XML comments. + * + * @since WP_VERSION + * + * @access private + */ + const STATE_CDATA_NODE = 'STATE_CDATA_NODE'; + + /** + * Indicates that the parser has found an XML processing instruction. + * + * @since WP_VERSION + * + * @access private + */ + const STATE_PI_NODE = 'STATE_PI_NODE'; + + /** + * Indicates that the parser has found an XML declaration + * + * @since WP_VERSION + * + * @access private + */ + const STATE_XML_DECLARATION = 'STATE_XML_DECLARATION'; + + /** + * Indicates that the parser has found an XML comment and it's + * possible to read and modify its modifiable text. + * + * @since WP_VERSION + * + * @access private + */ + const STATE_COMMENT = 'STATE_COMMENT'; + + /** + * Indicates that the parser encountered unsupported syntax and has bailed. + * + * @since WP_VERSION + * + * @var string + */ + const ERROR_SYNTAX = 'syntax'; + + /** + * Indicates that the provided XML document contains a declaration that is + * unsupported by the parser. + * + * @since WP_VERSION + * + * @var string + */ + const ERROR_UNSUPPORTED = 'unsupported'; + + /** + * Indicates that the parser encountered more XML tokens than it + * was able to process and has bailed. + * + * @since WP_VERSION + * + * @var string + */ + const ERROR_EXCEEDED_MAX_BOOKMARKS = 'exceeded-max-bookmarks'; +} diff --git a/packages/playground/data-liberation/tests/WPWXRURLRewriterTests.php b/packages/playground/data-liberation/tests/WPWXRURLRewriterTests.php new file mode 100644 index 0000000000..3d66d628f7 --- /dev/null +++ b/packages/playground/data-liberation/tests/WPWXRURLRewriterTests.php @@ -0,0 +1,31 @@ + new WP_File_Byte_Stream($fixture_path, 100), + 'wxr' => WP_WXR_URL_Rewrite_Processor::stream( + 'https://playground.internal/path', + 'https://playground.wordpress.net/new-path' + ), + ] + ); + $actual_output = $chain->run_to_completion(); + $expected_output = file_get_contents($expected_outcome_path); + $this->assertSame($expected_output, $actual_output); + } + + public function get_fixture_paths() { + return [ + [__DIR__ . '/fixtures/wxr-simple.xml', __DIR__ . '/fixtures/wxr-simple-expected.xml'], + ]; + } + +} diff --git a/packages/playground/data-liberation/tests/WPXMLProcessorTests.php b/packages/playground/data-liberation/tests/WPXMLProcessorTests.php new file mode 100644 index 0000000000..276eb7c311 --- /dev/null +++ b/packages/playground/data-liberation/tests/WPXMLProcessorTests.php @@ -0,0 +1,252 @@ + + + + +
' + ); + $processor->next_tag(); + $this->assertEquals( + array( 'wp:content' ), + $processor->get_breadcrumbs(), + 'get_breadcrumbs() did not return the expected breadcrumbs' + ); + + $processor->next_tag(); + $this->assertEquals( + array( 'wp:content', 'wp:text' ), + $processor->get_breadcrumbs(), + 'get_breadcrumbs() did not return the expected breadcrumbs' + ); + + $processor->next_tag(); + $this->assertEquals( + array( 'wp:content', 'wp:text', 'photo' ), + $processor->get_breadcrumbs(), + 'get_breadcrumbs() did not return the expected breadcrumbs' + ); + + $this->assertFalse( $processor->next_tag() ); + } + + /** + * @ticket 61365 + * + * @return void + */ + public function test_matches_breadcrumbs() { + // Initialize the WP_XML_Processor with the given XML string + $processor = new WP_XML_Processor( '' ); + + // Move to the next element with tag name 'img' + $processor->next_tag( 'image' ); + + // Assert that the breadcrumbs match the expected sequences + $this->assertTrue( $processor->matches_breadcrumbs( array( 'content', 'image' ) ) ); + $this->assertTrue( $processor->matches_breadcrumbs( array( 'wp:post', 'content', 'image' ) ) ); + $this->assertFalse( $processor->matches_breadcrumbs( array( 'wp:post', 'image' ) ) ); + $this->assertTrue( $processor->matches_breadcrumbs( array( 'wp:post', '*', 'image' ) ) ); + } + + /** + * @ticket 61365 + * + * @return void + */ + public function test_next_tag_by_breadcrumbs() { + // Initialize the WP_XML_Processor with the given XML string + $processor = new WP_XML_Processor( '' ); + + // Move to the next element with tag name 'img' + $processor->next_tag( + array( + 'breadcrumbs' => array( 'content', 'image' ), + ) + ); + + $this->assertEquals( 'image', $processor->get_tag(), 'Did not find the expected tag' ); + } + + /** + * @ticket 61365 + * + * @return void + */ + public function test_get_current_depth() { + // Initialize the WP_XML_Processor with the given XML string + $processor = new WP_XML_Processor( '' ); + + // Assert that the initial depth is 0 + $this->assertEquals( 0, $processor->get_current_depth() ); + + // Opening the root element increases the depth + $processor->next_tag(); + $this->assertEquals( 1, $processor->get_current_depth() ); + + // Opening the wp:text element increases the depth + $processor->next_tag(); + $this->assertEquals( 2, $processor->get_current_depth() ); + + // Opening the post element increases the depth + $processor->next_tag(); + $this->assertEquals( 3, $processor->get_current_depth() ); + + // Elements are closed during `next_tag()` so the depth is decreased to reflect that + $processor->next_tag(); + $this->assertEquals( 2, $processor->get_current_depth() ); + + // All elements are closed, so the depth is 0 + $processor->next_tag(); + $this->assertEquals( 0, $processor->get_current_depth() ); + } + + /** + * @ticket 61365 + * + * @expectedIncorrectUsage WP_XML_Processor::step_in_misc + */ + public function test_no_text_allowed_after_root_element() { + $processor = new WP_XML_Processor( 'text' ); + $this->assertTrue( $processor->next_tag(), 'Did not find a tag.' ); + $this->assertFalse( $processor->next_tag(), 'Found a non-existent tag.' ); + $this->assertEquals( + WP_XML_Tag_Processor::ERROR_SYNTAX, + $processor->get_last_error(), + 'Did not run into a parse error after the root element' + ); + } + + /** + * @ticket 61365 + */ + public function test_whitespace_text_allowed_after_root_element() { + $processor = new WP_XML_Processor( ' ' ); + $this->assertTrue( $processor->next_tag(), 'Did not find a tag.' ); + $this->assertFalse( $processor->next_tag(), 'Found a non-existent tag.' ); + $this->assertNull( $processor->get_last_error(), 'Ran into a parse error after the root element' ); + } + + /** + * @ticket 61365 + */ + public function test_processing_directives_allowed_after_root_element() { + $processor = new WP_XML_Processor( '' ); + $this->assertTrue( $processor->next_tag(), 'Did not find a tag.' ); + $this->assertFalse( $processor->next_tag(), 'Found a non-existent tag.' ); + $this->assertNull( $processor->get_last_error(), 'Ran into a parse error after the root element' ); + } + + /** + * @ticket 61365 + */ + public function test_mixed_misc_grammar_allowed_after_root_element() { + $processor = new WP_XML_Processor( ' ' ); + + $processor->next_tag(); + $this->assertEquals( 'root', $processor->get_tag(), 'Did not find a tag.' ); + + $processor->next_tag(); + $this->assertNull( $processor->get_last_error(), 'Did not run into a parse error after the root element' ); + } + + /** + * @ticket 61365 + * + * @expectedIncorrectUsage WP_XML_Processor::step_in_misc + */ + public function test_elements_not_allowed_after_root_element() { + $processor = new WP_XML_Processor( '' ); + $this->assertTrue( $processor->next_tag(), 'Did not find a tag.' ); + $this->assertFalse( $processor->next_tag(), 'Fount an illegal tag.' ); + $this->assertEquals( + WP_XML_Tag_Processor::ERROR_SYNTAX, + $processor->get_last_error(), + 'Did not run into a parse error after the root element' + ); + } + + /** + * @ticket 61365 + * + * @return void + */ + public function test_comments_allowed_after_root_element() { + $processor = new WP_XML_Processor( '' ); + $this->assertTrue( $processor->next_tag(), 'Did not find a tag.' ); + $this->assertFalse( $processor->next_tag(), 'Found an element node after the root element' ); + $this->assertNull( $processor->get_last_error(), 'Ran into a parse error after the root element' ); + } + + /** + * @ticket 61365 + * + * @expectedIncorrectUsage WP_XML_Processor::step_in_misc + * @return void + */ + public function test_cdata_not_allowed_after_root_element() { + $processor = new WP_XML_Processor( '' ); + $this->assertTrue( $processor->next_tag(), 'Did not find a tag.' ); + $this->assertFalse( $processor->next_tag(), 'Did not reject a comment node after the root element' ); + $this->assertEquals( + WP_XML_Tag_Processor::ERROR_SYNTAX, + $processor->get_last_error(), + 'Did not run into a parse error after the root element' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Processor::next_tag + */ + public function test_detects_invalid_document_no_root_tag() { + $processor = new WP_XML_Processor( + ' + ' + ); + $this->assertFalse( $processor->next_tag(), 'Found an element when there was none.' ); + $this->assertTrue( $processor->is_paused_at_incomplete_input(), 'Did not indicate that the XML input was incomplete.' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Processor::next_tag + */ + public function test_unclosed_root_yields_incomplete_input() { + $processor = new WP_XML_Processor( + ' + + + ' + ); + while ( $processor->next_tag() ) { + continue; + } + $this->assertTrue( $processor->is_paused_at_incomplete_input(), 'Did not indicate that the XML input was incomplete.' ); + } +} diff --git a/packages/playground/data-liberation/tests/WPXMLTagProcessorTests.php b/packages/playground/data-liberation/tests/WPXMLTagProcessorTests.php new file mode 100644 index 0000000000..c336371ec8 --- /dev/null +++ b/packages/playground/data-liberation/tests/WPXMLTagProcessorTests.php @@ -0,0 +1,1426 @@ +Text
'; + const XML_WITH_CLASSES = 'Text'; + const XML_MALFORMED = 'Back to notifications'; + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_tag + */ + public function test_get_tag_returns_null_before_finding_tags() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertNull( $processor->get_tag(), 'Calling get_tag() without selecting a tag did not return null' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_tag + */ + public function test_get_tag_returns_null_when_not_in_open_tag() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertFalse( $processor->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); + $this->assertNull( $processor->get_tag(), 'Accessing a non-existing tag did not return null' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_tag + */ + public function test_get_tag_returns_open_tag_name() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertTrue( $processor->next_tag( 'wp:content' ), 'Querying an existing tag did not return true' ); + $this->assertSame( 'wp:content', $processor->get_tag(), 'Accessing an existing tag name did not return "div"' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::is_empty_element + * + * @dataProvider data_is_empty_element + * + * @param string $xml Input XML whose first tag might contain the self-closing flag `/`. + * @param bool $flag_is_set Whether the input XML's first tag contains the self-closing flag. + */ + public function test_is_empty_element_matches_input_xml( $xml, $flag_is_set ) { + $processor = new WP_XML_Tag_Processor( $xml ); + $processor->next_tag( array( 'tag_closers' => 'visit' ) ); + + if ( $flag_is_set ) { + $this->assertTrue( $processor->is_empty_element(), 'Did not find the empty element tag when it was present.' ); + } else { + $this->assertFalse( $processor->is_empty_element(), 'Found the empty element tag when it was absent.' ); + } + } + + /** + * Data provider. XML tags which might have a self-closing flag, and an indicator if they do. + * + * @return array[] + */ + public static function data_is_empty_element() { + return array( + // These should not have a self-closer, and will leave an element un-closed if it's assumed they are self-closing. + 'Self-closing flag on non-void XML element' => array( '', true ), + 'No self-closing flag on non-void XML element' => array( '', false ), + // These should not have a self-closer, but are benign when used because the elements are void. + 'Self-closing flag on void XML element' => array( '', true ), + 'No self-closing flag on void XML element' => array( '', false ), + 'Self-closing flag on void XML element without spacing' => array( '', true ), + // These should not have a self-closer, but as part of a tag closer they are entirely ignored. + 'No self-closing flag on tag closer' => array( '', false ), + // These can and should have self-closers, and will leave an element un-closed if it's assumed they aren't self-closing. + 'Self-closing flag on a foreign element' => array( '', true ), + 'No self-closing flag on a foreign element' => array( '', false ), + // These involve syntax peculiarities. + 'Self-closing flag after extra spaces' => array( '', true ), + 'Self-closing flag after quoted attribute' => array( '', true ), + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_returns_null_when_not_in_open_tag() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertFalse( $processor->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); + $this->assertNull( $processor->get_attribute( 'wp:post-type' ), 'Accessing an attribute of a non-existing tag did not return null' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_returns_null_when_in_closing_tag() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertTrue( $processor->next_tag( 'wp:content' ), 'Querying an existing tag did not return true' ); + $this->assertTrue( $processor->next_tag( array( 'tag_closers' => 'visit' ) ), 'Querying an existing closing tag did not return true' ); + $this->assertNull( $processor->get_attribute( 'wp:post-type' ), 'Accessing an attribute of a closing tag did not return null' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_returns_null_when_attribute_missing() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertTrue( $processor->next_tag( 'wp:content' ), 'Querying an existing tag did not return true' ); + $this->assertNull( $processor->get_attribute( 'test-id' ), 'Accessing a non-existing attribute did not return null' ); + } + + /** + * @ticket 61365 + * + * @expectedIncorrectUsage WP_XML_Tag_Processor::base_class_next_token + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_attributes_are_rejected_in_tag_closers() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertTrue( $processor->next_tag( 'wp:content' ), 'Querying an existing tag did not return true' ); + $this->assertFalse( $processor->next_tag( array( 'tag_closers' => 'visit' ) ), 'Querying an existing but invalid closing tag did not return false.' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_returns_attribute_value() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertTrue( $processor->next_tag( 'wp:content' ), 'Querying an existing tag did not return true' ); + $this->assertSame( 'test', $processor->get_attribute( 'wp:post-type' ), 'Accessing a wp:post-type="test" attribute value did not return "test"' ); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::parse_next_attribute + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_parsing_stops_on_malformed_attribute_value_no_value() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertFalse( $processor->next_tag(), 'Querying a malformed start tag did not return false' ); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::parse_next_attribute + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_parsing_stops_on_malformed_attribute_value_no_quotes() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertFalse( $processor->next_tag(), 'Querying a malformed start tag did not return false' ); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::get_attribute + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_malformed_attribute_value_containing_ampersand_is_treated_as_plaintext() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertTrue( $processor->next_tag(), 'Querying a tag did not return true' ); + $this->assertEquals('WordPress & WordPress', $processor->get_attribute('enabled')); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::get_attribute + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_malformed_attribute_value_containing_entity_without_semicolon_is_treated_as_plaintext() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertTrue( $processor->next_tag(), 'Querying a tag did not return true' ); + $this->assertEquals('”', $processor->get_attribute('enabled')); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::parse_next_attribute + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_parsing_stops_on_malformed_attribute_value_contains_lt_character() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertFalse( $processor->next_tag(), 'Querying a malformed start tag did not return false' ); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::parse_next_attribute + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_parsing_stops_on_malformed_tags_duplicate_attributes() { + $processor = new WP_XML_Tag_Processor( 'Text' ); + + $this->assertFalse( $processor->next_tag() ); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::parse_next_attribute + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_parsing_stops_on_malformed_attribute_name_contains_slash() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + + $this->assertFalse( $processor->next_tag(), 'Querying a malformed start tag did not return false' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_modifiable_text_returns_a_decoded_value() { + $processor = new WP_XML_Tag_Processor( '“😄”' ); + + $processor->next_tag( 'root' ); + $processor->next_token(); + + $this->assertEquals( + '“😄”', + $processor->get_modifiable_text(), + 'Reading an encoded text did not decode it.' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_returns_a_decoded_value() { + $processor = new WP_XML_Tag_Processor( '' ); + + $this->assertTrue( $processor->next_tag( 'root' ), 'Querying a tag did not return true' ); + $this->assertEquals( + '“😄”', + $processor->get_attribute( 'encoded-data' ), + 'Reading an encoded attribute did not decode it.' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + * + * @param string $attribute_name Name of data-enabled attribute with case variations. + */ + public function test_get_attribute_is_case_sensitive() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag(); + + $this->assertEquals( + 'true', + $processor->get_attribute( 'DATA-enabled' ), + 'Accessing an attribute by a same-cased name did return not its value' + ); + + $this->assertNull( + $processor->get_attribute( 'data-enabled' ), + 'Accessing an attribute by a differently-cased name did return its value' + ); + } + + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::remove_attribute + */ + public function test_remove_attribute_is_case_sensitive() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag(); + $processor->remove_attribute( 'data-enabled' ); + + $this->assertSame( 'Test', $processor->get_updated_xml(), 'A case-sensitive remove_attribute call did remove the attribute' ); + + $processor->remove_attribute( 'DATA-enabled' ); + + $this->assertSame( 'Test', $processor->get_updated_xml(), 'A case-sensitive remove_attribute call did not remove the attribute' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::set_attribute + */ + public function test_set_attribute_is_case_sensitive() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag(); + $processor->set_attribute( 'data-enabled', 'abc' ); + + $this->assertSame( 'Test', $processor->get_updated_xml(), 'A case-insensitive set_attribute call did not update the existing attribute' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_returns_null_before_finding_tags() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $this->assertNull( + $processor->get_attribute_names_with_prefix( 'data-' ), + 'Accessing attributes by their prefix did not return null when no tag was selected' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_returns_null_when_not_in_open_tag() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag( 'p' ); + $this->assertNull( $processor->get_attribute_names_with_prefix( 'data-' ), 'Accessing attributes of a non-existing tag did not return null' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_returns_null_when_in_closing_tag() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag( 'wp:content' ); + $processor->next_tag( array( 'tag_closers' => 'visit' ) ); + + $this->assertNull( $processor->get_attribute_names_with_prefix( 'data-' ), 'Accessing attributes of a closing tag did not return null' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_returns_empty_array_when_no_attributes_present() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag( 'wp:content' ); + + $this->assertSame( array(), $processor->get_attribute_names_with_prefix( 'data-' ), 'Accessing the attributes on a tag without any did not return an empty array' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_returns_matching_attribute_names_in_original_case() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag(); + + $this->assertSame( + array( 'data-test-ID' ), + $processor->get_attribute_names_with_prefix( 'data-' ), + 'Accessing attributes by their prefix did not return their lowercase names' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_returns_attribute_added_by_set_attribute() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag(); + $processor->set_attribute( 'data-test-id', '14' ); + + $this->assertSame( + 'Test', + $processor->get_updated_xml(), + "Updated XML doesn't include attribute added via set_attribute" + ); + $this->assertSame( + array( 'data-test-id', 'data-foo' ), + $processor->get_attribute_names_with_prefix( 'data-' ), + "Accessing attribute names doesn't find attribute added via set_attribute" + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::__toString + */ + public function test_to_string_returns_updated_xml() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag(); + $processor->remove_attribute( 'id' ); + + $processor->next_tag(); + $processor->set_attribute( 'id', 'wp:content-id-1' ); + + $this->assertSame( + $processor->get_updated_xml(), + (string) $processor, + 'get_updated_xml() returned a different value than __toString()' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_updated_xml + */ + public function test_get_updated_xml_applies_the_updates_so_far_and_keeps_the_processor_on_the_current_tag() { + $processor = new WP_XML_Tag_Processor( 'Test' ); + $processor->next_tag(); + $processor->remove_attribute( 'id' ); + + $processor->next_tag(); + $processor->set_attribute( 'id', 'wp:content-id-1' ); + + $this->assertSame( + 'Test', + $processor->get_updated_xml(), + 'Calling get_updated_xml after updating the attributes of the second tag returned different XML than expected' + ); + + $processor->set_attribute( 'id', 'wp:content-id-2' ); + + $this->assertSame( + 'Test', + $processor->get_updated_xml(), + 'Calling get_updated_xml after updating the attributes of the second tag for the second time returned different XML than expected' + ); + + $processor->next_tag(); + $processor->remove_attribute( 'id' ); + + $this->assertSame( + 'Test', + $processor->get_updated_xml(), + 'Calling get_updated_xml after removing the id attribute of the third tag returned different XML than expected' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_updated_xml + */ + public function test_get_updated_xml_without_updating_any_attributes_returns_the_original_xml() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + + $this->assertSame( + self::XML_SIMPLE, + $processor->get_updated_xml(), + 'Casting WP_XML_Tag_Processor to a string without performing any updates did not return the initial XML snippet' + ); + } + + /** + * Ensures that when seeking to an earlier spot in the document that + * all previously-enqueued updates are applied as they ought to be. + * + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::parse_next_attribute + */ + public function test_get_updated_xml_applies_updates_to_content_after_seeking_to_before_parsed_bytes() { + $processor = new WP_XML_Tag_Processor( '' ); + + $processor->next_tag(); + $processor->set_attribute( 'wonky', 'true' ); + $processor->next_tag(); + $processor->set_bookmark( 'here' ); + + $processor->next_tag( array( 'tag_closers' => 'visit' ) ); + $processor->seek( 'here' ); + + $this->assertSame( '', $processor->get_updated_xml() ); + } + + public function test_declare_element_as_pcdata() { + $text = ' + This text contains syntax that may seem + like XML nodes: + + + + + + + &<>"' + + But! It is all treated as text. + '; + $processor = new WP_XML_Tag_Processor( + "$text" + ); + $processor->declare_element_as_pcdata( 'my-pcdata' ); + $processor->next_tag( 'my-pcdata' ); + + $this->assertEquals( + $text, + $processor->get_modifiable_text(), + 'get_modifiable_text() did not return the expected text' + ); + } + + /** + * Ensures that bookmarks start and length correctly describe a given token in XML. + * + * @ticket 61365 + * + * @dataProvider data_xml_nth_token_substring + * + * @param string $xml Input XML. + * @param int $match_nth_token Which token to inspect from input XML. + * @param string $expected_match Expected full raw token bookmark should capture. + */ + public function test_token_bookmark_span( string $xml, int $match_nth_token, string $expected_match ) { + $processor = new class( $xml ) extends WP_XML_Tag_Processor { + /** + * Returns the raw span of XML for the currently-matched + * token, or null if not paused on any token. + * + * @return string|null Raw XML content of currently-matched token, + * otherwise `null` if not matched. + */ + public function get_raw_token() { + if ( + WP_XML_Tag_Processor::STATE_READY === $this->parser_state || + WP_XML_Tag_Processor::STATE_INCOMPLETE_INPUT === $this->parser_state || + WP_XML_Tag_Processor::STATE_COMPLETE === $this->parser_state + ) { + return null; + } + + $this->set_bookmark( 'mark' ); + $mark = $this->bookmarks['mark']; + + return substr( $this->xml, $mark->start, $mark->length ); + } + }; + + for ( $i = 0; $i < $match_nth_token; $i++ ) { + $processor->next_token(); + } + + $raw_token = $processor->get_raw_token(); + $this->assertIsString( + $raw_token, + "Failed to find raw token at position {$match_nth_token}: check test data provider." + ); + + $this->assertSame( + $expected_match, + $raw_token, + 'Bookmarked wrong span of text for full matched token.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_xml_nth_token_substring() { + return array( + // Tags. + 'DIV start tag' => array( '', 1, '' ), + 'DIV start tag with attributes' => array( '', 1, '' ), + 'Nested DIV' => array( '', 2, '' ), + 'Sibling DIV' => array( '', 3, '' ), + 'DIV after text' => array( 'text ', 2, '' ), + 'DIV before text' => array( ' text', 1, '' ), + 'DIV after comment' => array( '', 3, '' ), + 'DIV before comment' => array( ' ', 1, '' ), + 'Start "self-closing" tag' => array( '', 1, '' ), + 'Void tag' => array( '', 1, '' ), + 'Void tag w/self-closing flag' => array( '', 1, '' ), + 'Void tag inside DIV' => array( '', 2, '' ), + + // Text. + 'Text' => array( 'Just text', 1, 'Just text' ), + 'Text in DIV' => array( 'Text', 2, 'Text' ), + 'Text before DIV' => array( 'Text', 1, 'Text' ), + 'Text after comment' => array( 'Text', 2, 'Text' ), + 'Text before comment' => array( 'Text ', 1, 'Text' ), + + // Comments. + 'Comment' => array( '', 1, '' ), + 'Comment in DIV' => array( '', 2, '' ), + 'Comment before DIV' => array( '', 1, '' ), + 'Comment after DIV' => array( '', 3, '' ), + 'Comment after comment' => array( '', 2, '' ), + 'Comment before comment' => array( ' ', 1, '' ), + 'Empty comment' => array( '', 1, '' ), + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::next_tag + */ + public function test_next_tag_with_no_arguments_should_find_the_next_existing_tag() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + + $this->assertTrue( $processor->next_tag(), 'Querying an existing tag did not return true' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::next_tag + */ + public function test_next_tag_should_return_false_for_a_non_existing_tag() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + + $this->assertFalse( $processor->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_modifiable_text + */ + public function test_normalizes_carriage_returns_in_text_nodes() { + $processor = new WP_XML_Tag_Processor( + "We are\rnormalizing\r\n\nthe\n\r\r\r\ncarriage returns" + ); + $processor->next_tag(); + $processor->next_token(); + $this->assertEquals( + "We are\nnormalizing\n\nthe\n\n\n\ncarriage returns", + $processor->get_modifiable_text(), + 'get_raw_token() did not normalize the carriage return characters' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_modifiable_text + */ + public function test_normalizes_carriage_returns_in_cdata() { + $processor = new WP_XML_Tag_Processor( + "" + ); + $processor->next_tag(); + $processor->next_token(); + $this->assertEquals( + "We are\nnormalizing\n\nthe\n\n\n\ncarriage returns", + $processor->get_modifiable_text(), + 'get_raw_token() did not normalize the carriage return characters' + ); + } + + /** + * @ticket 61365 + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::next_tag + * @covers WP_XML_Tag_Processor::is_tag_closer + */ + public function test_next_tag_should_stop_on_closers_only_when_requested() { + $processor = new WP_XML_Tag_Processor( '' ); + + $this->assertTrue( $processor->next_tag( array( 'tag_name' => 'wp:content' ) ), 'Did not find desired tag opener' ); + $this->assertFalse( $processor->next_tag( array( 'tag_name' => 'wp:content' ) ), 'Visited an unwanted tag, a tag closer' ); + + $processor = new WP_XML_Tag_Processor( '' ); + $processor->next_tag( + array( + 'tag_name' => 'wp:content', + 'tag_closers' => 'visit', + ) + ); + + $this->assertFalse( $processor->is_tag_closer(), 'Indicated a tag opener is a tag closer' ); + $this->assertTrue( + $processor->next_tag( + array( + 'tag_name' => 'wp:content', + 'tag_closers' => 'visit', + ) + ), + 'Did not stop at desired tag closer' + ); + $this->assertTrue( $processor->is_tag_closer(), 'Indicated a tag closer is a tag opener' ); + + $processor = new WP_XML_Tag_Processor( '' ); + $this->assertTrue( $processor->next_tag( array( 'tag_closers' => 'visit' ) ), "Did not find a tag opener when tag_closers was set to 'visit'" ); + $this->assertFalse( $processor->next_tag( array( 'tag_closers' => 'visit' ) ), "Found a closer where there wasn't one" ); + } + + /** + * Verifies that updates to a document before calls to `get_updated_xml()` don't + * lead to the Tag Processor jumping to the wrong tag after the updates. + * + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_updated_xml + */ + public function test_internal_pointer_returns_to_original_spot_after_inserting_content_before_cursor() { + $tags = new WP_XML_Tag_Processor( 'outside
inside
' ); + + $tags->next_tag(); + $tags->next_tag(); + $tags->set_attribute( 'wp:post-type', 'foo' ); + $tags->next_tag( 'section' ); + + // Return to this spot after moving ahead. + $tags->set_bookmark( 'here' ); + + // Move ahead. + $tags->next_tag( 'photo' ); + $tags->seek( 'here' ); + $this->assertSame( 'outside
inside
', $tags->get_updated_xml() ); + $this->assertSame( 'section', $tags->get_tag() ); + $this->assertFalse( $tags->is_tag_closer() ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::set_attribute + */ + public function test_set_attribute_on_a_non_existing_tag_does_not_change_the_markup() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + + $this->assertFalse( $processor->next_tag( 'p' ), 'Querying a non-existing tag did not return false' ); + $this->assertFalse( $processor->next_tag( 'wp:content' ), 'Querying a non-existing tag did not return false' ); + + $processor->set_attribute( 'id', 'primary' ); + + $this->assertSame( + self::XML_SIMPLE, + $processor->get_updated_xml(), + 'Calling get_updated_xml after updating a non-existing tag returned an XML that was different from the original XML' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::set_attribute + * @covers WP_XML_Tag_Processor::remove_attribute + * @covers WP_XML_Tag_Processor::add_class + * @covers WP_XML_Tag_Processor::remove_class + */ + public function test_attribute_ops_on_tag_closer_do_not_change_the_markup() { + $processor = new WP_XML_Tag_Processor( '' ); + $processor->next_tag( + array( + 'tag_name' => 'wp:content', + 'tag_closers' => 'visit', + ) + ); + + $this->assertFalse( $processor->is_tag_closer(), 'Skipped tag opener' ); + + $processor->next_tag( + array( + 'tag_name' => 'wp:content', + 'tag_closers' => 'visit', + ) + ); + + $this->assertTrue( $processor->is_tag_closer(), 'Skipped tag closer' ); + $this->assertFalse( $processor->set_attribute( 'id', 'test' ), "Allowed setting an attribute on a tag closer when it shouldn't have" ); + $this->assertFalse( $processor->remove_attribute( 'invalid-id' ), "Allowed removing an attribute on a tag closer when it shouldn't have" ); + $this->assertSame( + '', + $processor->get_updated_xml(), + 'Calling get_updated_xml after updating a non-existing tag returned an XML that was different from the original XML' + ); + } + + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::set_attribute + */ + public function test_set_attribute_with_a_non_existing_attribute_adds_a_new_attribute_to_the_markup() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->set_attribute( 'test-attribute', 'test-value' ); + + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Updated XML does not include attribute added via set_attribute()' + ); + $this->assertSame( + 'test-value', + $processor->get_attribute( 'test-attribute' ), + 'get_attribute() (called after get_updated_xml()) did not return attribute added via set_attribute()' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_returns_updated_values_before_they_are_applied() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->set_attribute( 'test-attribute', 'test-value' ); + + $this->assertSame( + 'test-value', + $processor->get_attribute( 'test-attribute' ), + 'get_attribute() (called before get_updated_xml()) did not return attribute added via set_attribute()' + ); + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Updated XML does not include attribute added via set_attribute()' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_returns_updated_values_before_they_are_applied_with_different_name_casing() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->set_attribute( 'test-ATTribute', 'test-value' ); + + $this->assertSame( + 'test-value', + $processor->get_attribute( 'test-ATTribute' ), + 'get_attribute() (called before get_updated_xml()) did not return attribute added via set_attribute()' + ); + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Updated XML does not include attribute added via set_attribute()' + ); + } + + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_reflects_removed_attribute_before_it_is_applied() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->remove_attribute( 'id' ); + + $this->assertNull( + $processor->get_attribute( 'id' ), + 'get_attribute() (called before get_updated_xml()) returned attribute that was removed by remove_attribute()' + ); + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Updated XML includes attribute that was removed by remove_attribute()' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_reflects_adding_and_then_removing_an_attribute_before_those_updates_are_applied() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->set_attribute( 'test-attribute', 'test-value' ); + $processor->remove_attribute( 'test-attribute' ); + + $this->assertNull( + $processor->get_attribute( 'test-attribute' ), + 'get_attribute() (called before get_updated_xml()) returned attribute that was added via set_attribute() and then removed by remove_attribute()' + ); + $this->assertSame( + self::XML_SIMPLE, + $processor->get_updated_xml(), + 'Updated XML includes attribute that was added via set_attribute() and then removed by remove_attribute()' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::get_attribute + */ + public function test_get_attribute_reflects_setting_and_then_removing_an_existing_attribute_before_those_updates_are_applied() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->set_attribute( 'id', 'test-value' ); + $processor->remove_attribute( 'id' ); + + $this->assertNull( + $processor->get_attribute( 'id' ), + 'get_attribute() (called before get_updated_xml()) returned attribute that was overwritten by set_attribute() and then removed by remove_attribute()' + ); + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Updated XML includes attribute that was overwritten by set_attribute() and then removed by remove_attribute()' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::set_attribute + */ + public function test_set_attribute_with_an_existing_attribute_name_updates_its_value_in_the_markup() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->set_attribute( 'id', 'new-id' ); + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Existing attribute was not updated' + ); + } + + /** + * Ensures that when setting an attribute multiple times that only + * one update flushes out into the updated XML. + * + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::set_attribute + */ + public function test_set_attribute_with_case_variants_updates_only_the_original_first_copy() { + $processor = new WP_XML_Tag_Processor( '' ); + $processor->next_tag(); + $processor->set_attribute( 'data-enabled', 'canary1' ); + $processor->set_attribute( 'data-enabled', 'canary2' ); + $processor->set_attribute( 'data-enabled', 'canary3' ); + + $this->assertSame( '', strtolower( $processor->get_updated_xml() ) ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::next_tag + * @covers WP_XML_Tag_Processor::set_attribute + */ + public function test_next_tag_and_set_attribute_in_a_loop_update_all_tags_in_the_markup() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + while ( $processor->next_tag() ) { + $processor->set_attribute( 'data-foo', 'bar' ); + } + + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Not all tags were updated when looping with next_tag() and set_attribute()' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::remove_attribute + */ + public function test_remove_attribute_with_an_existing_attribute_name_removes_it_from_the_markup() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->remove_attribute( 'id' ); + + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Attribute was not removed' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::remove_attribute + */ + public function test_remove_attribute_with_a_non_existing_attribute_name_does_not_change_the_markup() { + $processor = new WP_XML_Tag_Processor( self::XML_SIMPLE ); + $processor->next_tag(); + $processor->remove_attribute( 'no-such-attribute' ); + + $this->assertSame( + self::XML_SIMPLE, + $processor->get_updated_xml(), + 'Content was changed when attempting to remove an attribute that did not exist' + ); + } + + /** + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::next_tag + */ + public function test_correctly_parses_xml_attributes_wrapped_in_single_quotation_marks() { + $processor = new WP_XML_Tag_Processor( + 'Text' + ); + $processor->next_tag( + array( + 'tag_name' => 'wp:content', + 'id' => 'first', + ) + ); + $processor->remove_attribute( 'id' ); + $processor->next_tag( + array( + 'tag_name' => 'wp:text', + 'id' => 'second', + ) + ); + $processor->set_attribute( 'id', 'single-quote' ); + $this->assertSame( + 'Text', + $processor->get_updated_xml(), + 'Did not remove single-quoted attribute' + ); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::parse_next_attribute + * @expectedIncorrectUsage WP_XML_Tag_Processor::set_attribute + * + * @covers WP_XML_Tag_Processor::set_attribute + */ + public function test_setting_an_attribute_to_false_is_rejected() { + $processor = new WP_XML_Tag_Processor( + '
' + ); + $processor->next_tag( 'input' ); + $this->assertFalse( + $processor->set_attribute( 'checked', false ), + 'Accepted a boolean attribute name.' + ); + } + + /** + * @ticket 61365 + * @expectedIncorrectUsage WP_XML_Tag_Processor::set_attribute + * + * @covers WP_XML_Tag_Processor::set_attribute + */ + public function test_setting_a_missing_attribute_to_false_does_not_change_the_markup() { + $xml_input = '
'; + $processor = new WP_XML_Tag_Processor( $xml_input ); + $processor->next_tag( 'input' ); + $processor->set_attribute( 'checked', false ); + $this->assertSame( + $xml_input, + $processor->get_updated_xml(), + 'Changed the markup unexpectedly when setting a non-existing attribute to false' + ); + } + + /** + * Ensures that unclosed and invalid comments trigger warnings or errors. + * + * @ticket 61365 + * + * @covers WP_XML_Tag_Processor::next_tag + * @covers WP_XML_Tag_Processor::paused_at_incomplete_token + * + * @dataProvider data_xml_with_unclosed_comments + * + * @param string $xml_ending_before_comment_close XML with opened comments that aren't closed. + */ + public function test_documents_may_end_with_unclosed_comment( $xml_ending_before_comment_close ) { + $processor = new WP_XML_Tag_Processor( $xml_ending_before_comment_close ); + + $this->assertFalse( + $processor->next_tag(), + "Should not have found any tag, but found {$processor->get_tag()}." + ); + + $this->assertTrue( + $processor->is_paused_at_incomplete_input(), + "Should have indicated that the parser found an incomplete token but didn't." + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_xml_with_unclosed_comments() { + return array( + 'Shortest open valid comment' => array( '' ); + $this->assertFalse( $processor->next_token(), 'Did not reject a malformed XML comment.' ); + } + + /** + * @covers WP_XML_Tag_Processor::next_tag + */ + public function test_handles_malformed_taglike_open_short_xml() { + $processor = new WP_XML_Tag_Processor( '<' ); + $result = $processor->next_tag(); + $this->assertFalse( $result, 'Did not handle "<" xml properly.' ); + } + + /** + * @covers WP_XML_Tag_Processor::next_tag + */ + public function test_handles_malformed_taglike_close_short_xml() { + $processor = new WP_XML_Tag_Processor( 'next_tag(); + $this->assertFalse( $result, 'Did not handle " ' ); + $result = $processor->next_tag(); + $this->assertFalse( $result, 'Did not handle "
" xml properly.' ); + } + + /** + * Ensures that non-tag syntax starting with `<` is rejected. + * + * @ticket 61365 + */ + public function test_single_text_node_with_taglike_text() { + $processor = new WP_XML_Tag_Processor( 'This is a text node< /A>' ); + $this->assertTrue( $processor->next_token(), 'A valid text node was not found.' ); + $this->assertEquals( 'This is a text node', $processor->get_modifiable_text(), 'The contents of a valid text node were not correctly captured.' ); + $this->assertFalse( $processor->next_tag(), 'A malformed XML markup was not rejected.' ); + } + + /** + * Ensures that non-tag syntax starting with `<` is rejected. + * + * @ticket 61365 + */ + public function test_parses_CDATA() { + $processor = new WP_XML_Tag_Processor( '' ); + $processor->next_tag(); + $this->assertTrue( $processor->next_token(), 'The first text node was not found.' ); $this->assertEquals( + 'This is a CDATA text node.', + $processor->get_modifiable_text(), + 'The contents of a a CDATA text node were not correctly captured.' + ); + } + + /** + * @ticket 61365 + */ + public function test_yields_CDATA_a_separate_text_node() { + $processor = new WP_XML_Tag_Processor( 'This is the first text node and this is the third text node.' ); + + $processor->next_token(); + $this->assertTrue( $processor->next_token(), 'The first text node was not found.' ); + $this->assertEquals( + 'This is the first text node ', + $processor->get_modifiable_text(), + 'The contents of a valid text node were not correctly captured.' + ); + + $this->assertTrue( $processor->next_token(), 'The CDATA text node was not found.' ); + $this->assertEquals( + ' and this is a second text node ', + $processor->get_modifiable_text(), + 'The contents of a a CDATA text node were not correctly captured.' + ); + + $this->assertTrue( $processor->next_token(), 'The text node was not found.' ); + $this->assertEquals( + ' and this is the third text node.', + $processor->get_modifiable_text(), + 'The contents of a valid text node were not correctly captured.' + ); + } + + /** + * + * @ticket 61365 + */ + public function test_xml_declaration() { + $processor = new WP_XML_Tag_Processor( '' ); + $this->assertTrue( $processor->next_token(), 'The XML declaration was not found.' ); + $this->assertEquals( + '#xml-declaration', + $processor->get_token_type(), + 'The XML declaration was not correctly identified.' + ); + $this->assertEquals( '1.0', $processor->get_attribute( 'version' ), 'The version attribute was not correctly captured.' ); + $this->assertEquals( 'UTF-8', $processor->get_attribute( 'encoding' ), 'The encoding attribute was not correctly captured.' ); + } + + /** + * + * @ticket 61365 + */ + public function test_xml_declaration_with_single_quotes() { + $processor = new WP_XML_Tag_Processor( "" ); + $this->assertTrue( $processor->next_token(), 'The XML declaration was not found.' ); + $this->assertEquals( + '#xml-declaration', + $processor->get_token_type(), + 'The XML declaration was not correctly identified.' + ); + $this->assertEquals( '1.0', $processor->get_attribute( 'version' ), 'The version attribute was not correctly captured.' ); + $this->assertEquals( 'UTF-8', $processor->get_attribute( 'encoding' ), 'The encoding attribute was not correctly captured.' ); + } + + /** + * + * @ticket 61365 + */ + public function test_processor_instructions() { + $processor = new WP_XML_Tag_Processor( + // The first ' . + // The second ' + ); + $this->assertTrue( $processor->next_token(), 'The XML declaration was not found.' ); + $this->assertTrue( $processor->next_token(), 'The processing instruction was not found.' ); + $this->assertEquals( + '#processing-instructions', + $processor->get_token_type(), + 'The processing instruction was not correctly identified.' + ); + $this->assertEquals( ' stylesheet type="text/xsl" href="style.xsl" ', $processor->get_modifiable_text(), 'The modifiable text was not correctly captured.' ); + } + + /** + * Ensures that updates which are enqueued in front of the cursor + * are applied before moving forward in the document. + * + * @ticket 61365 + */ + public function test_applies_updates_before_proceeding() { + $xml = ''; + + $subclass = new class( $xml ) extends WP_XML_Tag_Processor { + /** + * Inserts raw text after the current token. + * + * @param string $new_xml Raw text to insert. + */ + public function insert_after( $new_xml ) { + $this->set_bookmark( 'here' ); + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $this->bookmarks['here']->start + $this->bookmarks['here']->length, + 0, + $new_xml + ); + } + }; + + $subclass->next_tag( 'photo' ); + $subclass->insert_after( '

snow-capped

' ); + + $subclass->next_tag(); + $this->assertSame( + 'p', + $subclass->get_tag(), + 'Should have matched inserted XML as next tag.' + ); + + $subclass->next_tag( 'photo' ); + $subclass->set_attribute( 'alt', 'mountain' ); + + $this->assertSame( + '

snow-capped

', + $subclass->get_updated_xml(), + 'Should have properly applied the update from in front of the cursor.' + ); + } +} \ No newline at end of file diff --git a/packages/playground/data-liberation/tests/fixtures/wxr-simple-expected.xml b/packages/playground/data-liberation/tests/fixtures/wxr-simple-expected.xml new file mode 100644 index 0000000000..a9601c3607 --- /dev/null +++ b/packages/playground/data-liberation/tests/fixtures/wxr-simple-expected.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + My WordPress Website + https://playground.wordpress.net/new-path/ + + Mon, 10 Jun 2024 12:29:10 +0000 + en-US + 1.2 + https://playground.wordpress.net/new-path/ + https://playground.wordpress.net/new-path/ + + + 1 + + + + + + + + + https://wordpress.org/?v=6.5.4 + + + <![CDATA["The Road Not Taken" by Robert Frost]]> + https://playground.wordpress.net/new-path/?p=1 + Wed, 05 Jun 2024 16:04:48 +0000 + + https://playground.wordpress.net/new-path/?p=1 + + +

Two roads diverged in a yellow wood,
And sorry I could not travel both

+ + + +

+One seemed great, but the other seemed great too. +There was also a third option, but it was not as great. + +playground.wordpress.net/new-path/one was the best choice. +https://playground.internal/path-not-taken was the second best choice. +

+]]>
+ + 10 + + + + + + + + + 0 + 0 + + + 0 + + + + + + + + + +
+
+
\ No newline at end of file diff --git a/packages/playground/data-liberation/tests/fixtures/wxr-simple.xml b/packages/playground/data-liberation/tests/fixtures/wxr-simple.xml new file mode 100644 index 0000000000..f7c4b13b07 --- /dev/null +++ b/packages/playground/data-liberation/tests/fixtures/wxr-simple.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + My WordPress Website + https://playground.internal/path + + Mon, 10 Jun 2024 12:29:10 +0000 + en-US + 1.2 + https://playground.internal/path + https://playground.internal/path + + + 1 + + + + + + + + + https://wordpress.org/?v=6.5.4 + + + <![CDATA["The Road Not Taken" by Robert Frost]]> + https://playground.internal/path/?p=1 + Wed, 05 Jun 2024 16:04:48 +0000 + + https://playground.internal/path/?p=1 + + +

Two roads diverged in a yellow wood,
And sorry I could not travel both

+ + + +

+One seemed great, but the other seemed great too. +There was also a third option, but it was not as great. + +playground.internal/path/one was the best choice. +https://playground.internal/path-not-taken was the second best choice. +

+]]>
+ + 10 + + + + + + + + + 0 + 0 + + + 0 + + + + + + + + + +
+
+
+ \ No newline at end of file diff --git a/packages/playground/website/src/components/import-form/modal.tsx b/packages/playground/website/src/components/import-form/modal.tsx index 158617a239..5861a60b98 100644 --- a/packages/playground/website/src/components/import-form/modal.tsx +++ b/packages/playground/website/src/components/import-form/modal.tsx @@ -22,19 +22,19 @@ export const ImportFormModal = () => { } return ( - - - + onRequestClose={closeModal} + > + + ); }; diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index dcf05c6eb6..0ad70177c9 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -40,8 +40,8 @@ export const modalSlugs = { LOG: 'log', ERROR_REPORT: 'error-report', START_ERROR: 'start-error', - IMPORT_FORM: 'import-form' -} + IMPORT_FORM: 'import-form', +}; const displayMode = getDisplayModeFromQuery(); function getDisplayModeFromQuery(): DisplayMode { diff --git a/tsconfig.base.json b/tsconfig.base.json index 89722ad56c..658b6a1796 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,112 +1,81 @@ { - "compileOnSave": false, - "compilerOptions": { - "rootDir": ".", - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "esModuleInterop": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "importHelpers": true, - "resolveJsonModule": true, - "jsx": "react", - "target": "ES2021", - "module": "esnext", - "lib": [ - "ES2022", - "esnext.disposable", - "dom" - ], - "skipLibCheck": true, - "skipDefaultLibCheck": true, - "baseUrl": ".", - "paths": { - "@php-wasm/cli": [ - "packages/php-wasm/cli/src/main.ts" - ], - "@php-wasm/fs-journal": [ - "packages/php-wasm/fs-journal/src/index.ts" - ], - "@php-wasm/logger": [ - "packages/php-wasm/logger/src/index.ts" - ], - "@php-wasm/node": [ - "packages/php-wasm/node/src/index.ts" - ], - "@php-wasm/node-polyfills": [ - "packages/php-wasm/node-polyfills/src/index.ts" - ], - "@php-wasm/private": [ - "packages/php-wasm/private/src/index.ts" - ], - "@php-wasm/progress": [ - "packages/php-wasm/progress/src/index.ts" - ], - "@php-wasm/scopes": [ - "packages/php-wasm/scopes/src/index.ts" - ], - "@php-wasm/stream-compression": [ - "packages/php-wasm/stream-compression/src/index.ts" - ], - "@php-wasm/universal": [ - "packages/php-wasm/universal/src/index.ts" - ], - "@php-wasm/util": [ - "packages/php-wasm/util/src/index.ts" - ], - "@php-wasm/web": [ - "packages/php-wasm/web/src/index.ts" - ], - "@php-wasm/web-service-worker": [ - "packages/php-wasm/web-service-worker/src/index.ts" - ], - "@wp-playground/blueprints": [ - "packages/playground/blueprints/src/index.ts" - ], - "@wp-playground/cli": [ - "packages/playground/cli/src/cli.ts" - ], - "@wp-playground/client": [ - "packages/playground/client/src/index.ts" - ], - "@wp-playground/common": [ - "packages/playground/common/src/index.ts" - ], - "@wp-playground/components": [ - "packages/playground/components/src/index.ts" - ], - "@wp-playground/nx-extensions": [ - "packages/nx-extensions/src/index.ts" - ], - "@wp-playground/remote": [ - "packages/playground/remote/src/index.ts" - ], - "@wp-playground/storage": [ - "packages/playground/storage/src/index.ts" - ], - "@wp-playground/sync": [ - "packages/playground/sync/src/index.ts" - ], - "@wp-playground/unit-test-utils": [ - "packages/playground/unit-test-utils/src/index.ts" - ], - "@wp-playground/website": [ - "packages/playground/website/src/index.ts" - ], - "@wp-playground/wordpress": [ - "packages/playground/wordpress/src/index.ts" - ], - "@wp-playground/wordpress-builds": [ - "packages/playground/wordpress-builds/src/index.ts" - ], - "isomorphic-git": [ - "./isomorphic-git/src" - ] - } - }, - "exclude": [ - "node_modules", - "tmp" - ] + "compileOnSave": false, + "compilerOptions": { + "rootDir": ".", + "sourceMap": true, + "declaration": false, + "moduleResolution": "node", + "esModuleInterop": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "resolveJsonModule": true, + "jsx": "react", + "target": "ES2021", + "module": "esnext", + "lib": ["ES2022", "esnext.disposable", "dom"], + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "baseUrl": ".", + "paths": { + "@php-wasm/cli": ["packages/php-wasm/cli/src/main.ts"], + "@php-wasm/fs-journal": [ + "packages/php-wasm/fs-journal/src/index.ts" + ], + "@php-wasm/logger": ["packages/php-wasm/logger/src/index.ts"], + "@php-wasm/node": ["packages/php-wasm/node/src/index.ts"], + "@php-wasm/node-polyfills": [ + "packages/php-wasm/node-polyfills/src/index.ts" + ], + "@php-wasm/private": ["packages/php-wasm/private/src/index.ts"], + "@php-wasm/progress": ["packages/php-wasm/progress/src/index.ts"], + "@php-wasm/scopes": ["packages/php-wasm/scopes/src/index.ts"], + "@php-wasm/stream-compression": [ + "packages/php-wasm/stream-compression/src/index.ts" + ], + "@php-wasm/universal": ["packages/php-wasm/universal/src/index.ts"], + "@php-wasm/util": ["packages/php-wasm/util/src/index.ts"], + "@php-wasm/web": ["packages/php-wasm/web/src/index.ts"], + "@php-wasm/web-service-worker": [ + "packages/php-wasm/web-service-worker/src/index.ts" + ], + "@wp-playground/blueprints": [ + "packages/playground/blueprints/src/index.ts" + ], + "@wp-playground/cli": ["packages/playground/cli/src/cli.ts"], + "@wp-playground/client": [ + "packages/playground/client/src/index.ts" + ], + "@wp-playground/common": [ + "packages/playground/common/src/index.ts" + ], + "@wp-playground/components": [ + "packages/playground/components/src/index.ts" + ], + "@wp-playground/nx-extensions": [ + "packages/nx-extensions/src/index.ts" + ], + "@wp-playground/remote": [ + "packages/playground/remote/src/index.ts" + ], + "@wp-playground/storage": [ + "packages/playground/storage/src/index.ts" + ], + "@wp-playground/sync": ["packages/playground/sync/src/index.ts"], + "@wp-playground/unit-test-utils": [ + "packages/playground/unit-test-utils/src/index.ts" + ], + "@wp-playground/website": [ + "packages/playground/website/src/index.ts" + ], + "@wp-playground/wordpress": [ + "packages/playground/wordpress/src/index.ts" + ], + "@wp-playground/wordpress-builds": [ + "packages/playground/wordpress-builds/src/index.ts" + ], + "isomorphic-git": ["./isomorphic-git/src"] + } + }, + "exclude": ["node_modules", "tmp"] }