Skip to content

Commit

Permalink
feat: use WP_HTML_Tag_Processor for AttributesOutputFilter
Browse files Browse the repository at this point in the history
  • Loading branch information
shvlv committed Mar 15, 2024
1 parent 332b725 commit 623c5e4
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 278 deletions.
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
"mikey179/vfsstream": "^1.6.8",
"inpsyde/php-coding-standards": "^1",
"vimeo/psalm": "@stable",
"php-stubs/wordpress-stubs": ">=6.0@stable",
"johnpbloch/wordpress-core": ">=6.0"
"php-stubs/wordpress-stubs": ">=6.2@stable",
"johnpbloch/wordpress-core": ">=6.2"
},
"autoload": {
"psr-4": {
Expand Down
7 changes: 5 additions & 2 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" backupStaticAttributes="false" bootstrap="tests/phpunit/bootstrap.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd" backupGlobals="false" backupStaticAttributes="false" bootstrap="tests/phpunit/bootstrap.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" convertDeprecationsToExceptions="true" processIsolation="false" stopOnFailure="false">
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
Expand All @@ -14,7 +14,10 @@
</coverage>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/phpunit/Unit</directory>
<directory>tests/phpunit/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/phpunit/Integration</directory>
</testsuite>
</testsuites>
<logging/>
Expand Down
96 changes: 23 additions & 73 deletions src/OutputFilter/AttributesOutputFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,105 +15,55 @@

use Inpsyde\Assets\Asset;

/**
* @psalm-suppress UndefinedMethod
*/
class AttributesOutputFilter implements AssetOutputFilter
{
private const ROOT_ELEMENT_START = '<root>';
private const ROOT_ELEMENT_END = '</root>';

/**
* @param string $html
* @param Asset $asset
*
* @return string
*
* phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
* @psalm-suppress PossiblyFalseArgument
* @psalm-suppress ArgumentTypeCoercion
*/
public function __invoke(string $html, Asset $asset): string
{
$attributes = $asset->attributes();
if (count($attributes) === 0) {
return $html;
}

$html = $this->wrapHtmlIntoRoot($html);

$doc = new \DOMDocument();
libxml_use_internal_errors(true);
@$doc->loadHTML(
mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, ~0], "UTF-8"),
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
if (!class_exists(\WP_HTML_Tag_Processor::class)) {
// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error(
'Adding attributes is not supported for WordPress < 6.2',
\E_USER_DEPRECATED
);
// phpcs:enable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error

$scripts = $doc->getElementsByTagName('script');
foreach ($scripts as $script) {
// Only extend <script> elements with "src" attribute
// and don't extend inline <script></script> before and after.
if (!$script->hasAttribute('src')) {
continue;
}
$this->applyAttributes($script, $attributes);
return $html;

Check warning on line 35 in src/OutputFilter/AttributesOutputFilter.php

View check run for this annotation

Codecov / codecov/patch

src/OutputFilter/AttributesOutputFilter.php#L35

Added line #L35 was not covered by tests
}

return $this->removeRootElement(html_entity_decode($doc->saveHTML()));
}

/**
* Wrapping multiple scripts into a root-element
* to be able to load it via DOMDocument.
*
* @param string $html
*
* @return string
*/
protected function wrapHtmlIntoRoot(string $html): string
{
return self::ROOT_ELEMENT_START . $html . self::ROOT_ELEMENT_END;
}
$tags = new \WP_HTML_Tag_Processor($html);

/**
* Remove root element and return original HTML.
*
* @param string $html
*
* @return string
* @see AttributesOutputFilter::wrapHtmlIntoRoot()
*
*/
protected function removeRootElement(string $html): string
{
$regex = '~' . self::ROOT_ELEMENT_START . '(.+?)' . self::ROOT_ELEMENT_END . '~s';
preg_match($regex, $html, $matches);
// Only extend <script> elements with "src" attribute
// and don't extend inline <script></script> before and after.
if (
$tags->next_tag(['tag_name' => 'script'])
&& (string) $tags->get_attribute('src')
) {
$this->applyAttributes($tags, $attributes);
}

return $matches[1];
return $tags->get_updated_html();
}

/**
* @param \DOMElement $script
* @param array $attributes
*
* @return void
*/
protected function applyAttributes(\DOMElement $script, array $attributes)
protected function applyAttributes(\WP_HTML_Tag_Processor $script, array $attributes): void
{
foreach ($attributes as $key => $value) {
$key = esc_attr((string) $key);
if ($script->hasAttribute($key)) {
$key = esc_attr((string)$key);
if ((string) $script->get_attribute($key)) {
continue;
}
if (is_bool($value) && !$value) {
continue;
}
$value = is_bool($value)
? esc_attr($key)
: esc_attr((string) $value);
: esc_attr((string)$value);

$script->setAttribute($key, $value);
$script->set_attribute($key, $value);
}
}
}
227 changes: 227 additions & 0 deletions tests/phpunit/Integration/OutputFilter/AttributesOutputFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Assets package.
*
* (c) Inpsyde GmbH
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Inpsyde\Assets\Tests\Integration\OutputFilter;

use Inpsyde\Assets\OutputFilter\AssetOutputFilter;
use Inpsyde\Assets\OutputFilter\AttributesOutputFilter;
use Inpsyde\Assets\Script;
use PHPUnit\Framework\TestCase;

/**
* @runTestsInSeparateProcesses
*/
class AttributesOutputFilterTest extends TestCase
{
public static function setUpBeforeClass(): void
{
if (!class_exists(\WP_HTML_Tag_Processor::class)) {
require ABSPATH . 'wp-includes/html-api/class-wp-html-attribute-token.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-span.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-text-replacement.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-tag-processor.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-unsupported-exception.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-active-formatting-elements.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-open-elements.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-token.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-processor-state.php';
require ABSPATH . 'wp-includes/html-api/class-wp-html-processor.php';
}

if (!function_exists('esc_attr')) {
eval('function esc_attr(string $attribute): string
{
return $attribute;
}');
}
}

public function testBasic(): void
{
$testee = new AttributesOutputFilter();

$stub = new Script('stub-script', 'https://syde.com/foo.js');

$input = '<script src="foo.js"></script>';

static::assertInstanceOf(AssetOutputFilter::class, $testee);
static::assertSame($input, $testee($input, $stub));
}

/**
* @dataProvider provideAttributes
*/
public function testRenderWithAttributes(array $attributes, array $expected, array $notExpected): void
{
$stub = new Script('stub-script', 'https://syde.com/foo.js');
$stub->withAttributes($attributes);

$input = '<script src="script.js"></script>';

$testee = new AttributesOutputFilter();
$output = $testee($input, $stub);

foreach ($expected as $test) {
static::assertStringContainsString($test, $output);
}
foreach ($notExpected as $test) {
static::assertStringNotContainsString($test, $output);
}
}

/**
* @return \Generator
*/
public function provideAttributes(): \Generator
{
yield 'string value' => [
[
'key' => 'value',
],
['key="value"'],
[],
];

yield 'integer value' => [
[
'key' => 1,
],
['key="1"'],
[],
];

yield 'bool true value' => [
[
'key' => true,
],
['key="key"'],
[],
];

yield 'bool false value' => [
[
'key' => false,
],
[],
['key="key"'],
];

yield 'overwriting src-attribute' => [
[
'key' => 'value',
'src' => 'not-allowed.js',
],
['key="value"'],
['src="not-allowed.js"'],
];
}

public function testRenderNotOverwriteExistingAttributes(): void
{
$expectedKey = 'src';
$expectedValue = 'foo.js';
$expectedAttribute = sprintf('%s="%s"', $expectedKey, $expectedValue);

$stub = new Script('stub-script', 'https://syde.com/foo.js');
// We're trying to overwrite the "src" with "bar.js".
$stub->withAttributes([$expectedKey => 'bar.js']);

$input = sprintf('<script %s></script>', $expectedAttribute);

$testee = new AttributesOutputFilter();
static::assertStringContainsString($expectedAttribute, $testee($input, $stub));
}

public function testRenderInlineScriptsNotChanged()
{
$expectedKey = 'key';
$expectedValue = 'value';
$expectedAttributes = [$expectedKey => $expectedValue];

$expectedBefore = "<script>var before = 'bar';</script>";
$expectedAfter = "<script>var after = 'bar';</script>";

$stub = new Script('stub-script', 'https://syde.com/foo.js');
$stub->withAttributes($expectedAttributes);

$input = $expectedBefore . '<script src="foo.js"></script>' . $expectedAfter;

$testee = new AttributesOutputFilter();
$output = $testee($input, $stub);
static::assertStringContainsString($expectedBefore, $output);
static::assertStringContainsString($expectedAfter, $output);
}

/**
* @dataProvider provideRenderWithInlineScripts
*/
public function testRenderWithInlineScripts(string $expectedBefore, string $expectedAfter): void
{
$stub = new Script('stub-script', 'https://syde.com/foo.js');
$stub->withAttributes(['foo' => 'bar']);

$input = $expectedBefore . '<script src="foo.js"></script>' . $expectedAfter;

$testee = new AttributesOutputFilter();
$output = $testee($input, $stub);
static::assertStringContainsString($expectedBefore, $output);
static::assertStringContainsString($expectedAfter, $output);
}

public function provideRenderWithInlineScripts(): \Generator
{
$singleLineJs = '(function(){ console.log("script with single line"); })();';
$multiLineJs = <<<JS
(function() {
console.log("script with multiple lines")
})();
JS;
$multiByteLine = '<script>(function(){ console.log("Lösungen ї 𠀋"); })();</script>';
$nonStandardUrl = '<script src="http://[::1]:5173/path/to/build/@vite/client"></script>';

yield 'before single line' => [
$singleLineJs,
'',
];

yield 'after single line' => [
'',
$singleLineJs,
];

yield 'before and after single line' => [
$singleLineJs,
$singleLineJs,
];

yield 'before multi, after single line' => [
$multiLineJs,
$singleLineJs,
];

yield 'before and after multi line' => [
$multiLineJs,
$multiLineJs,
];

yield 'before and after multibyte line' => [
$multiByteLine,
$multiByteLine,
];

yield 'before and after URL with non-alphanumeric characters' => [
$nonStandardUrl,
$nonStandardUrl,
];
}
}
Loading

0 comments on commit 623c5e4

Please sign in to comment.