From ad7e126cb1bd64b2db76cd1e2c88b7a3f9d919ac Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 5 Sep 2024 13:35:41 -0700 Subject: [PATCH 1/3] Bring PHP functionality in line with recent JS changes and add integration tests for third parties (#70) * Update PHPStan level to 6 and fix violations, bump min PHP to 7.2, update GitHub workflows. * Update composer.lock. * Ensure correct namespace usage and line lengths. * Add support for script optionalParams to PHP codebase. * Fix bugs in data formatter class. * Prevent override of functions that should not be overridden. * Fix test based on code generation bug fix. * Add missing support for null values in HTML attributes. * Add PHP integration test coverage for the third parties. * Align multiple subsequent assignments. * Fix alignment. * Add support for Google Tag Manager. * Update references to the JS codebase in PHP methods. * Add comment to clarify. * Fix failing test following GA schema changes. --- inc/Data/ThirdPartyDataFormatter.php | 96 ++++++++++++------- inc/Data/ThirdPartyHtmlAttributes.php | 7 +- inc/Data/ThirdPartyHtmlData.php | 3 - inc/Data/ThirdPartyScriptData.php | 23 ++++- inc/ThirdParties/GoogleTagManager.php | 29 ++++++ inc/ThirdParties/ThirdPartyBase.php | 12 +-- inc/Util/HtmlAttributes.php | 22 +++-- .../Data/ThirdPartyDataFormatterTest.php | 12 +-- .../ThirdParties/GoogleAnalyticsTest.php | 90 +++++++++++++++++ .../ThirdParties/GoogleMapsEmbedTest.php | 75 +++++++++++++++ .../ThirdParties/GoogleTagManagerTest.php | 89 +++++++++++++++++ .../tests/ThirdParties/YouTubeEmbedTest.php | 72 ++++++++++++++ .../phpunit/tests/Util/HtmlAttributesTest.php | 9 ++ tests/phpunit/utils/TestCase.php | 22 +++++ 14 files changed, 501 insertions(+), 60 deletions(-) create mode 100644 inc/ThirdParties/GoogleTagManager.php create mode 100644 tests/phpunit/tests/ThirdParties/GoogleAnalyticsTest.php create mode 100644 tests/phpunit/tests/ThirdParties/GoogleMapsEmbedTest.php create mode 100644 tests/phpunit/tests/ThirdParties/GoogleTagManagerTest.php create mode 100644 tests/phpunit/tests/ThirdParties/YouTubeEmbedTest.php diff --git a/inc/Data/ThirdPartyDataFormatter.php b/inc/Data/ThirdPartyDataFormatter.php index 5dfd179..a8c79c4 100644 --- a/inc/Data/ThirdPartyDataFormatter.php +++ b/inc/Data/ThirdPartyDataFormatter.php @@ -20,7 +20,7 @@ class ThirdPartyDataFormatter /** * Formats third party data for a given set of input arguments and returns the corresponding output. * - * @see https://github.com/GoogleChromeLabs/third-party-capital/blob/0831b937a8468e0f74bd79edd5a59fa8b2e6e763/src/utils/index.ts#L94 + * @see https://github.com/GoogleChromeLabs/third-party-capital/blob/54cd44d1bd197a7809ab2f6ede4d13a973087c3d/src/utils/index.ts#L105 * * @param ThirdPartyData $data Third party data to format. * @param array $args Input arguments to format third party data with. @@ -31,15 +31,20 @@ public static function formatData(ThirdPartyData $data, array $args): ThirdParty $htmlData = $data->getHtml(); $scriptsData = $data->getScripts(); - $allScriptParams = array_reduce( - $scriptsData, - static function ($acc, ThirdPartyScriptData $scriptData) { - foreach ($scriptData->getParams() as $param) { - $acc[] = $param; - } - return $acc; - }, - [] + $allScriptParams = array_unique( + array_reduce( + $scriptsData, + static function ($acc, ThirdPartyScriptData $scriptData) { + foreach ($scriptData->getParams() as $param) { + $acc[] = $param; + } + foreach (array_keys($scriptData->getOptionalParams()) as $param) { + $acc[] = $param; + } + return $acc; + }, + [] + ) ); $scriptUrlParamInputs = self::intersectArgs($args, $allScriptParams); @@ -84,20 +89,24 @@ static function ($acc, ThirdPartyScriptData $scriptData) { } if (isset($newData['scripts']) && $newData['scripts']) { $newData['scripts'] = array_map( - static function ($scriptData) use ($scriptUrlParamInputs) { + static function ($scriptData) use ($allScriptParams, $scriptUrlParamInputs) { if (isset($scriptData['url'])) { $scriptData['url'] = self::formatUrl( $scriptData['url'], - $scriptData['params'], - $scriptUrlParamInputs + $allScriptParams, + $scriptUrlParamInputs, + [], + $scriptData['optionalParams'] ?? [] ); } else { $scriptData['code'] = self::formatCode( $scriptData['code'], - $scriptUrlParamInputs + $scriptUrlParamInputs, + $scriptData['optionalParams'] ?? [] ); } - unset($scriptData['params']); // Params are irrelevant for formatted output. + // Params are irrelevant for formatted output. + unset($scriptData['params'], $scriptData['optionalParams']); return $scriptData; }, $newData['scripts'] @@ -110,7 +119,7 @@ static function ($scriptData) use ($scriptUrlParamInputs) { /** * Formats the given HTML arguments into an HTML string. * - * @see https://github.com/GoogleChromeLabs/third-party-capital/blob/0831b937a8468e0f74bd79edd5a59fa8b2e6e763/src/utils/index.ts#L55 + * @see https://github.com/GoogleChromeLabs/third-party-capital/blob/54cd44d1bd197a7809ab2f6ede4d13a973087c3d/src/utils/index.ts#L66 * * @param string $element Element tag name for the HTML element. * @param array $attributes Attributes for the HTML element. @@ -152,17 +161,24 @@ public static function formatHtml( /** * Formats the given URL arguments into a URL string. * - * @see https://github.com/GoogleChromeLabs/third-party-capital/blob/0831b937a8468e0f74bd79edd5a59fa8b2e6e763/src/utils/index.ts#L28 + * @see https://github.com/GoogleChromeLabs/third-party-capital/blob/54cd44d1bd197a7809ab2f6ede4d13a973087c3d/src/utils/index.ts#L28 * - * @param string $url Base URL. - * @param string[] $params Parameter names. - * @param array $args Input arguments for the src attribute query parameters. - * @param array $slugParamArg Optional. Input argument for the src attribute slug query parameter. - * Default empty array. + * @param string $url Base URL. + * @param string[] $params Parameter names. + * @param array $args Input arguments for the src attribute query parameters. + * @param array $slugParamArg Optional. Input argument for the src attribute slug query parameter. + * Default empty array. + * @param array $optionalParams Optional. Optional parameter names and their defaults. + * Default empty array. * @return string HTML string. */ - public static function formatUrl(string $url, array $params, array $args, array $slugParamArg = []): string - { + public static function formatUrl( + string $url, + array $params, + array $args, + array $slugParamArg = [], + array $optionalParams = [] + ): string { if ($slugParamArg) { $slug = array_values($slugParamArg)[0]; @@ -181,6 +197,14 @@ public static function formatUrl(string $url, array $params, array $args, array if ($params && $args) { $queryArgs = self::intersectArgs($args, $params); + if ($optionalParams) { + $optionalArgs = self::intersectArgs($optionalParams, $params); + foreach ($optionalArgs as $k => $v) { + if (!isset($queryArgs[$k])) { + $queryArgs[$k] = $v; + } + } + } if ($queryArgs) { $url = self::setUrlQueryArgs($url, $queryArgs); } @@ -192,21 +216,29 @@ public static function formatUrl(string $url, array $params, array $args, array /** * Formats the given code arguments into a code string. * - * @see https://github.com/GoogleChromeLabs/third-party-capital/blob/0831b937a8468e0f74bd79edd5a59fa8b2e6e763/src/utils/index.ts#L48 + * @see https://github.com/GoogleChromeLabs/third-party-capital/blob/54cd44d1bd197a7809ab2f6ede4d13a973087c3d/src/utils/index.ts#L52 * - * @param string $code Code string with placeholders for URL query parameters. - * @param array $args Input arguments for the src attribute query parameters. + * @param string $code Code string with placeholders for URL query parameters. + * @param array $args Input arguments for the src attribute query parameters. + * @param array $optionalParams Optional. Optional parameter names and their defaults. + * Default empty array. * @return string HTML string. */ - public static function formatCode(string $code, array $args): string - { + public static function formatCode( + string $code, + array $args, + array $optionalParams = [] + ): string { return preg_replace_callback( '/{{([^}]+)}}/', - static function ($matches) use ($args) { + static function ($matches) use ($args, $optionalParams) { if (isset($args[ $matches[1] ])) { - return $args[ $matches[1] ]; + return json_encode($args[ $matches[1] ]); + } + if (isset($optionalParams[ $matches[1] ])) { + return json_encode($optionalParams[ $matches[1] ]); } - return ''; + return '""'; // The same as `json_encode('')`. }, $code ); diff --git a/inc/Data/ThirdPartyHtmlAttributes.php b/inc/Data/ThirdPartyHtmlAttributes.php index 4722934..051d7d9 100644 --- a/inc/Data/ThirdPartyHtmlAttributes.php +++ b/inc/Data/ThirdPartyHtmlAttributes.php @@ -9,6 +9,7 @@ namespace GoogleChromeLabs\ThirdPartyCapital\Data; +use GoogleChromeLabs\ThirdPartyCapital\Contracts\Arrayable; use GoogleChromeLabs\ThirdPartyCapital\Util\HtmlAttributes; /** @@ -22,7 +23,7 @@ class ThirdPartyHtmlAttributes extends HtmlAttributes * * @param string $name Attribute name. * @param mixed $value Attribute value. - * @return mixed Sanitized attribute value. + * @return string|bool|null|Arrayable Sanitized attribute value. */ protected function sanitizeAttr(string $name, $value) { @@ -36,8 +37,8 @@ protected function sanitizeAttr(string $name, $value) /** * Returns the attribute string for the given attribute name and value. * - * @param string $name Attribute name. - * @param mixed $value Attribute value. + * @param string $name Attribute name. + * @param string|bool|null|Arrayable $value Attribute value. * @return string HTML attribute string (starts with a space), or empty string to skip. */ protected function toAttrString(string $name, $value): string diff --git a/inc/Data/ThirdPartyHtmlData.php b/inc/Data/ThirdPartyHtmlData.php index d956f7b..182ed7b 100644 --- a/inc/Data/ThirdPartyHtmlData.php +++ b/inc/Data/ThirdPartyHtmlData.php @@ -80,9 +80,6 @@ private function validateData(array $htmlData): void if (!isset($htmlData['attributes'])) { throw new InvalidThirdPartyDataException('Missing HTML attributes.'); } - if (!isset($htmlData['attributes']['src'])) { - throw new InvalidThirdPartyDataException('Missing HTML src attribute.'); - } } /** diff --git a/inc/Data/ThirdPartyScriptData.php b/inc/Data/ThirdPartyScriptData.php index 5d6f036..1ed8484 100644 --- a/inc/Data/ThirdPartyScriptData.php +++ b/inc/Data/ThirdPartyScriptData.php @@ -76,6 +76,13 @@ class ThirdPartyScriptData implements Arrayable */ private $params; + /** + * Optional parameters for the script and their defaults, if needed. + * + * @var array + */ + private $optionalParams; + /** * Constructor. * @@ -159,6 +166,16 @@ public function getParams(): array return $this->params; } + /** + * Gets the optional parameters for the script with their defaults, if needed. + * + * @return array Optional parameters for the script, if needed. + */ + public function getOptionalParams(): array + { + return $this->optionalParams; + } + /** * Determines whether the script is an external script. * @@ -192,6 +209,9 @@ public function toArray(): array if ($this->params) { $data['params'] = $this->params; } + if ($this->optionalParams) { + $data['optionalParams'] = $this->optionalParams; + } return $data; } @@ -278,6 +298,7 @@ private function setData(array $scriptData): void $this->$field = isset($scriptData[ $field ]) ? (string) $scriptData[ $field ] : ''; } - $this->params = isset($scriptData['params']) ? array_map('strval', $scriptData['params']) : []; + $this->params = isset($scriptData['params']) ? array_map('strval', $scriptData['params']) : []; + $this->optionalParams = isset($scriptData['optionalParams']) ? (array) $scriptData['optionalParams'] : []; } } diff --git a/inc/ThirdParties/GoogleTagManager.php b/inc/ThirdParties/GoogleTagManager.php new file mode 100644 index 0000000..19d66a5 --- /dev/null +++ b/inc/ThirdParties/GoogleTagManager.php @@ -0,0 +1,29 @@ + $args Input arguments to set. */ - public function __construct(array $args) + final public function __construct(array $args) { $this->jsonFilePath = $this->getJsonFilePath(); @@ -65,7 +65,7 @@ public function __construct(array $args) * * @return string Third party identifier. */ - public function getId(): string + final public function getId(): string { $this->lazilyInitialize(); @@ -77,7 +77,7 @@ public function getId(): string * * @param array $args Input arguments to set. */ - public function setArgs(array $args): void + final public function setArgs(array $args): void { $this->args = $args; @@ -92,7 +92,7 @@ public function setArgs(array $args): void * * @return string HTML output, or empty string if not applicable. */ - public function getHtml(): string + final public function getHtml(): string { $this->lazilyInitialize(); @@ -106,7 +106,7 @@ public function getHtml(): string * * @return string[] List of stylesheet URLs, or empty array if not applicable. */ - public function getStylesheets(): array + final public function getStylesheets(): array { $this->lazilyInitialize(); @@ -120,7 +120,7 @@ public function getStylesheets(): array * * @return ThirdPartyScriptOutput[] List of script definition objects, or empty array if not applicable. */ - public function getScripts(): array + final public function getScripts(): array { $this->lazilyInitialize(); diff --git a/inc/Util/HtmlAttributes.php b/inc/Util/HtmlAttributes.php index cb6ce05..2b9d953 100644 --- a/inc/Util/HtmlAttributes.php +++ b/inc/Util/HtmlAttributes.php @@ -19,8 +19,8 @@ /** * Class representing a set of HTML Attributes. * - * @implements ArrayAccess - * @implements IteratorAggregate + * @implements ArrayAccess + * @implements IteratorAggregate */ class HtmlAttributes implements Arrayable, ArrayAccess, IteratorAggregate { @@ -28,7 +28,7 @@ class HtmlAttributes implements Arrayable, ArrayAccess, IteratorAggregate /** * Internal attributes storage. * - * @var array + * @var array */ private $attr = []; @@ -64,7 +64,7 @@ public function offsetExists($name) * @since n.e.x.t * * @param string $name Attribute name. - * @return mixed Value for the given attribute. + * @return string|bool|null|Arrayable Value for the given attribute. * * @throws NotFoundException Thrown if the attribute is not set. */ @@ -115,7 +115,7 @@ public function offsetUnset($name) * * @since n.e.x.t * - * @return ArrayIterator Attributes iterator. + * @return ArrayIterator Attributes iterator. */ public function getIterator(): Traversable { @@ -159,11 +159,11 @@ public function __toString(): string * * @param string $name Attribute name. * @param mixed $value Attribute value. - * @return mixed Sanitized attribute value. + * @return string|bool|null|Arrayable Sanitized attribute value. */ protected function sanitizeAttr(string $name, $value) { - if (is_bool($value)) { + if (is_bool($value) || is_null($value)) { return $value; } return (string) $value; @@ -172,12 +172,16 @@ protected function sanitizeAttr(string $name, $value) /** * Returns the attribute string for the given attribute name and value. * - * @param string $name Attribute name. - * @param mixed $value Attribute value. + * @param string $name Attribute name. + * @param string|bool|null|Arrayable $value Attribute value. * @return string HTML attribute string (starts with a space), or empty string to skip. */ protected function toAttrString(string $name, $value): string { + if (is_null($value)) { + return ''; + } + if (is_bool($value)) { return $value ? ' ' . $name : ''; } diff --git a/tests/phpunit/tests/Data/ThirdPartyDataFormatterTest.php b/tests/phpunit/tests/Data/ThirdPartyDataFormatterTest.php index f5a4719..749bf84 100644 --- a/tests/phpunit/tests/Data/ThirdPartyDataFormatterTest.php +++ b/tests/phpunit/tests/Data/ThirdPartyDataFormatterTest.php @@ -333,11 +333,11 @@ public function testFormatUrlWithQueryAndSlugParamAndParamsAndArgs() public function testFormatCodeWithoutArgs() { $code = ThirdPartyDataFormatter::formatCode( - 'document.querySelector("{{selector}}").addEventListener(api.{{callback}});', + 'document.querySelector({{selector}}).addEventListener(api[{{callback}}]);', [] ); $this->assertSame( - 'document.querySelector("").addEventListener(api.);', + 'document.querySelector("").addEventListener(api[""]);', $code ); } @@ -345,14 +345,14 @@ public function testFormatCodeWithoutArgs() public function testFormatCodeWithArgs() { $code = ThirdPartyDataFormatter::formatCode( - 'document.querySelector("{{selector}}").addEventListener(api.{{callback}});', + 'document.querySelector({{selector}}).addEventListener(api[{{callback}}]);', [ 'selector' => '.my-cta-button', 'callback' => 'addToCart', ] ); $this->assertSame( - 'document.querySelector(".my-cta-button").addEventListener(api.addToCart);', + 'document.querySelector(".my-cta-button").addEventListener(api["addToCart"]);', $code ); } @@ -360,7 +360,7 @@ public function testFormatCodeWithArgs() public function testFormatCodeWithArgsIncorrectOrderAndTooMany() { $code = ThirdPartyDataFormatter::formatCode( - 'document.querySelector("{{selector}}").addEventListener(api.{{callback}});', + 'document.querySelector({{selector}}).addEventListener(api[{{callback}}]);', [ 'callback' => 'addToCart', 'device' => 'phone', @@ -368,7 +368,7 @@ public function testFormatCodeWithArgsIncorrectOrderAndTooMany() ] ); $this->assertSame( - 'document.querySelector(".my-cta-button").addEventListener(api.addToCart);', + 'document.querySelector(".my-cta-button").addEventListener(api["addToCart"]);', $code ); } diff --git a/tests/phpunit/tests/ThirdParties/GoogleAnalyticsTest.php b/tests/phpunit/tests/ThirdParties/GoogleAnalyticsTest.php new file mode 100644 index 0000000..fb2bdc4 --- /dev/null +++ b/tests/phpunit/tests/ThirdParties/GoogleAnalyticsTest.php @@ -0,0 +1,90 @@ +assertSame('google-analytics', $ga->getId()); + $this->assertSame('', $ga->getHtml()); + $this->assertSame([], $ga->getStylesheets()); + $this->assertSame( + $expectedScripts, + array_map( + static function ($script) { + return $script->toArray(); + }, + $ga->getScripts() + ) + ); + } + + public function dataOutput(): array + { + $consentDefault = '{"ad_user_data":"denied","ad_personalization":"denied","ad_storage":"denied","analytics_storage":"denied","wait_for_update":500}'; + return [ + 'basic example' => [ + [ 'id' => 'G-12345678' ], + [ + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'url' => 'https://www.googletagmanager.com/gtag/js?id=G-12345678', + 'key' => 'gtag', + ], + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'code' => "window[\"dataLayer\"]=window[\"dataLayer\"]||[];window['gtag-'+\"dataLayer\"]=function (){window[\"dataLayer\"].push(arguments);};window['gtag-'+\"dataLayer\"]('consent', \"default\", {$consentDefault});window['gtag-'+\"dataLayer\"]('js',new Date());window['gtag-'+\"dataLayer\"]('config',\"G-12345678\")", + 'key' => 'setup', + ], + ], + ], + 'with custom data layer' => [ + [ + 'id' => 'G-13579', + 'l' => 'myDataLayer1', + ], + [ + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'url' => 'https://www.googletagmanager.com/gtag/js?id=G-13579&l=myDataLayer1', + 'key' => 'gtag', + ], + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'code' => "window[\"myDataLayer1\"]=window[\"myDataLayer1\"]||[];window['gtag-'+\"myDataLayer1\"]=function (){window[\"myDataLayer1\"].push(arguments);};window['gtag-'+\"myDataLayer1\"]('consent', \"default\", {$consentDefault});window['gtag-'+\"myDataLayer1\"]('js',new Date());window['gtag-'+\"myDataLayer1\"]('config',\"G-13579\")", + 'key' => 'setup', + ], + ], + ], + ]; + } +} diff --git a/tests/phpunit/tests/ThirdParties/GoogleMapsEmbedTest.php b/tests/phpunit/tests/ThirdParties/GoogleMapsEmbedTest.php new file mode 100644 index 0000000..ea7ea03 --- /dev/null +++ b/tests/phpunit/tests/ThirdParties/GoogleMapsEmbedTest.php @@ -0,0 +1,75 @@ +assertSame('google-maps-embed', $gme->getId()); + $this->assertSame($expectedHtml, $gme->getHtml()); + $this->assertSame([], $gme->getStylesheets()); + $this->assertSame([], $gme->getScripts()); + } + + public function dataOutput(): array + { + return [ + 'basic example' => [ + [ + 'key' => 'MY_API_KEY', + 'q' => 'Space Needle, Seattle WA', + ], + $this->getHtmlString( + 'iframe', + [ + 'loading' => 'lazy', + 'src' => 'https://www.google.com/maps/embed/v1/place?key=MY_API_KEY&q=Space+Needle%2C+Seattle+WA', + 'referrerpolicy' => 'no-referrer-when-downgrade', + 'frameborder' => '0', + 'style' => 'border:0', + 'allowfullscreen' => true, + ] + ), + ], + 'with custom mode' => [ + [ + 'mode' => 'search', + 'key' => 'MY_API_KEY', + 'q' => 'tourist attractions in Seattle', + 'maptype' => 'satellite', + ], + $this->getHtmlString( + 'iframe', + [ + 'loading' => 'lazy', + 'src' => 'https://www.google.com/maps/embed/v1/search?key=MY_API_KEY&q=tourist+attractions+in+Seattle&maptype=satellite', + 'referrerpolicy' => 'no-referrer-when-downgrade', + 'frameborder' => '0', + 'style' => 'border:0', + 'allowfullscreen' => true, + ] + ), + ], + ]; + } +} diff --git a/tests/phpunit/tests/ThirdParties/GoogleTagManagerTest.php b/tests/phpunit/tests/ThirdParties/GoogleTagManagerTest.php new file mode 100644 index 0000000..aec448d --- /dev/null +++ b/tests/phpunit/tests/ThirdParties/GoogleTagManagerTest.php @@ -0,0 +1,89 @@ +assertSame('google-tag-manager', $gtm->getId()); + $this->assertSame('', $gtm->getHtml()); + $this->assertSame([], $gtm->getStylesheets()); + $this->assertSame( + $expectedScripts, + array_map( + static function ($script) { + return $script->toArray(); + }, + $gtm->getScripts() + ) + ); + } + + public function dataOutput(): array + { + return [ + 'basic example' => [ + [ 'id' => 'GTM-12345678' ], + [ + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'url' => 'https://www.googletagmanager.com/gtm.js?id=GTM-12345678', + 'key' => 'gtm', + ], + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'code' => "window[\"dataLayer\"]=window[\"dataLayer\"]||[];window[\"dataLayer\"].push({'gtm.start':new Date().getTime(),event:'gtm.js'});", + 'key' => 'setup', + ], + ], + ], + 'with custom data layer' => [ + [ + 'id' => 'GTM-A1B2C3', + 'l' => 'myDataLayer1', + ], + [ + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'url' => 'https://www.googletagmanager.com/gtm.js?id=GTM-A1B2C3&l=myDataLayer1', + 'key' => 'gtm', + ], + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'code' => "window[\"myDataLayer1\"]=window[\"myDataLayer1\"]||[];window[\"myDataLayer1\"].push({'gtm.start':new Date().getTime(),event:'gtm.js'});", + 'key' => 'setup', + ], + ], + ], + ]; + } +} diff --git a/tests/phpunit/tests/ThirdParties/YouTubeEmbedTest.php b/tests/phpunit/tests/ThirdParties/YouTubeEmbedTest.php new file mode 100644 index 0000000..67cc12e --- /dev/null +++ b/tests/phpunit/tests/ThirdParties/YouTubeEmbedTest.php @@ -0,0 +1,72 @@ +assertSame('youtube-embed', $yte->getId()); + $this->assertSame($expectedHtml, $yte->getHtml()); + $this->assertSame( + ['https://cdn.jsdelivr.net/gh/paulirish/lite-youtube-embed@master/src/lite-yt-embed.css'], + $yte->getStylesheets() + ); + $this->assertSame( + [ + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_IDLE, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'url' => 'https://cdn.jsdelivr.net/gh/paulirish/lite-youtube-embed@master/src/lite-yt-embed.js', + 'key' => 'lite-yt-embed', + ], + ], + array_map( + static function ($script) { + return $script->toArray(); + }, + $yte->getScripts() + ) + ); + } + + public function dataOutput(): array + { + return [ + 'basic example' => [ + [ + 'videoid' => 'ogfYd705cRs', + 'playlabel' => 'Play: Keynote (Google I/O 2018)', + ], + $this->getHtmlString( + 'lite-youtube', + [ + 'videoid' => 'ogfYd705cRs', + 'playlabel' => 'Play: Keynote (Google I/O 2018)', + ] + ), + ], + ]; + } +} diff --git a/tests/phpunit/tests/Util/HtmlAttributesTest.php b/tests/phpunit/tests/Util/HtmlAttributesTest.php index 7e039f1..5338537 100644 --- a/tests/phpunit/tests/Util/HtmlAttributesTest.php +++ b/tests/phpunit/tests/Util/HtmlAttributesTest.php @@ -140,6 +140,15 @@ public function dataToString() ], ' id="unique-id" async class="demo-class"', ], + 'with null' => [ + [ + 'id' => 'some-id', + 'width' => null, + 'height' => null, + 'class' => 'demo-class', + ], + ' id="some-id" class="demo-class"', + ], ]; } } diff --git a/tests/phpunit/utils/TestCase.php b/tests/phpunit/utils/TestCase.php index 0cfebcb..285bf7b 100644 --- a/tests/phpunit/utils/TestCase.php +++ b/tests/phpunit/utils/TestCase.php @@ -16,6 +16,28 @@ abstract class TestCase extends PHPUnitTestCase { + /** + * Test helper to turn a HTML element name and attributes array into a string. + * + * @param string $element HTML element name. + * @param array $attributes Associative array of HTML attributes. + * @return string The resulting HTML string. + */ + protected function getHtmlString(string $element, array $attributes): string + { + $attr_string = ''; + foreach ($attributes as $key => $value) { + if (is_bool($value)) { + if ($value) { + $attr_string .= ' ' . $key; + } + continue; + } + $attr_string .= ' ' . $key . '="' . $value . '"'; + } + return '<' . $element . $attr_string . '>'; + } + protected function runGetterTestCase(string $className, string $getMethod, array $args, $expected) { if (is_subclass_of($expected, Exception::class)) { From 1efad1c6b7a33274f80cbae995e4e0ab36bcd174 Mon Sep 17 00:00:00 2001 From: Thorsten Kober Date: Tue, 10 Sep 2024 14:49:45 -0400 Subject: [PATCH 2/3] refactor utils (#74) --- src/utils/index.test.ts | 2 +- src/utils/index.ts | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index dfd0185..21f1e6a 100644 --- a/src/utils/index.test.ts +++ b/src/utils/index.test.ts @@ -328,7 +328,7 @@ describe('Utils', () => { params: { val: null, }, - output: `null`, + output: `undefined`, }, // undefined { diff --git a/src/utils/index.ts b/src/utils/index.ts index 2834526..2c06f2b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -40,8 +40,14 @@ export function formatUrl( if (params && args) { params.forEach((param: string) => { if (args[param]) newUrl.searchParams.set(param, args[param]); - else if (optionalParams?.[param]) { - newUrl.searchParams.set(param, optionalParams?.[param]); + }); + } + + if (optionalParams) { + Object.keys(optionalParams).forEach((key: string) => { + if (args?.[key]) newUrl.searchParams.set(key, args[key]); + else if (optionalParams[key]) { + newUrl.searchParams.set(key, optionalParams[key]); } }); } @@ -56,9 +62,8 @@ export function formatCode( ) { return code.replace(/{{(.*?)}}/g, (match) => { const name = match.split(/{{|}}/).filter(Boolean)[0]; - return JSON.stringify( - args?.[name] !== undefined ? args?.[name] : optionalParams?.[name], - ); + + return JSON.stringify(args?.[name] ?? optionalParams?.[name] ?? undefined); }); } @@ -157,19 +162,15 @@ export function formatData(data: Data, args: Inputs): Output { ...script, url: formatUrl( script.url, - allScriptParams, - scriptUrlParamInputs, + script.params, + args, undefined, script.optionalParams, ), } : { ...script, - code: formatCode( - script.code, - scriptUrlParamInputs, - script.optionalParams, - ), + code: formatCode(script.code, args, script.optionalParams), }; }) : undefined, From 62c950c4ed8b0904f02b2ba882a146d8fdc49eed Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 11 Sep 2024 13:02:05 -0700 Subject: [PATCH 3/3] Implement a very simple conditional templating system (#73) * Try implementing a very simple conditional templating system. * Fix tests. * Fix tests. * Implement template conditionals in PHP. * Update Google Analytics to use conditionals for consent and set default consentValues to null. * Fix bug resolved in #74 on the PHP side too. * Ignore PHP vendor folder for ESLint. * Improve regex. * Improve regex. * Improve regex and add another test for malformed code. * Try again. * Revert "Try again." This reverts commit 4717ea04f100a6fb09b802abaa7f69a87936b1e1. --- .eslintignore | 3 +- data/google-analytics.json | 10 +-- inc/Data/ThirdPartyDataFormatter.php | 43 +++++++++---- src/utils/index.test.ts | 40 ++++++++++++ src/utils/index.ts | 14 ++++- .../Data/ThirdPartyDataFormatterTest.php | 40 ++++++++++++ .../ThirdParties/GoogleAnalyticsTest.php | 61 ++++++++++++++++++- 7 files changed, 185 insertions(+), 26 deletions(-) diff --git a/.eslintignore b/.eslintignore index 3d61567..6e35e5d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,4 +2,5 @@ node_modules/ .DS_Store lib/ .husky -dist/ \ No newline at end of file +dist/ +vendor/ \ No newline at end of file diff --git a/data/google-analytics.json b/data/google-analytics.json index 2d2ad24..ed03bba 100644 --- a/data/google-analytics.json +++ b/data/google-analytics.json @@ -15,18 +15,12 @@ "key": "gtag" }, { - "code": "window[{{l}}]=window[{{l}}]||[];window['gtag-'+{{l}}]=function (){window[{{l}}].push(arguments);};window['gtag-'+{{l}}]('consent', {{consentType}}, {{consentValues}});window['gtag-'+{{l}}]('js',new Date());window['gtag-'+{{l}}]('config',{{id}})", + "code": "window[{{l}}]=window[{{l}}]||[];window['gtag-'+{{l}}]=function (){window[{{l}}].push(arguments);};{{#consentValues}}window['gtag-'+{{l}}]('consent', {{consentType}}, {{consentValues}});{{/consentValues}}window['gtag-'+{{l}}]('js',new Date());window['gtag-'+{{l}}]('config',{{id}})", "params": ["id"], "optionalParams": { "l": "dataLayer", "consentType": "default", - "consentValues": { - "ad_user_data": "denied", - "ad_personalization": "denied", - "ad_storage": "denied", - "analytics_storage": "denied", - "wait_for_update": 500 - } + "consentValues": null }, "strategy": "worker", "location": "head", diff --git a/inc/Data/ThirdPartyDataFormatter.php b/inc/Data/ThirdPartyDataFormatter.php index a8c79c4..e83d1a8 100644 --- a/inc/Data/ThirdPartyDataFormatter.php +++ b/inc/Data/ThirdPartyDataFormatter.php @@ -89,19 +89,19 @@ static function ($acc, ThirdPartyScriptData $scriptData) { } if (isset($newData['scripts']) && $newData['scripts']) { $newData['scripts'] = array_map( - static function ($scriptData) use ($allScriptParams, $scriptUrlParamInputs) { + static function ($scriptData) use ($args) { if (isset($scriptData['url'])) { $scriptData['url'] = self::formatUrl( $scriptData['url'], - $allScriptParams, - $scriptUrlParamInputs, + $scriptData['params'] ?? [], + $args, [], $scriptData['optionalParams'] ?? [] ); } else { $scriptData['code'] = self::formatCode( $scriptData['code'], - $scriptUrlParamInputs, + $args, $scriptData['optionalParams'] ?? [] ); } @@ -195,19 +195,22 @@ public static function formatUrl( } } + $queryArgs = []; if ($params && $args) { $queryArgs = self::intersectArgs($args, $params); - if ($optionalParams) { - $optionalArgs = self::intersectArgs($optionalParams, $params); - foreach ($optionalArgs as $k => $v) { - if (!isset($queryArgs[$k])) { - $queryArgs[$k] = $v; - } + } + if ($optionalParams) { + foreach ($optionalParams as $k => $v) { + if (isset($args[$k])) { + $queryArgs[$k] = $args[$k]; + } elseif ($v) { + $queryArgs[$k] = $v; } } - if ($queryArgs) { - $url = self::setUrlQueryArgs($url, $queryArgs); - } + } + + if ($queryArgs) { + $url = self::setUrlQueryArgs($url, $queryArgs); } return $url; @@ -229,6 +232,20 @@ public static function formatCode( array $args, array $optionalParams = [] ): string { + // Conditionals. + $code = preg_replace_callback( + '/{{#([^{}]+?)}}(.*){{\/\1}}/', + static function ($matches) use ($args, $optionalParams) { + if ((isset($args[ $matches[1] ]) && $args[ $matches[1] ]) || + (isset($optionalParams[ $matches[1] ]) && $optionalParams[ $matches[1] ])) { + return $matches[2]; + } + return ''; + }, + $code + ); + + // Variables. return preg_replace_callback( '/{{([^}]+)}}/', static function ($matches) use ($args, $optionalParams) { diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index 21f1e6a..dd6a2f1 100644 --- a/src/utils/index.test.ts +++ b/src/utils/index.test.ts @@ -343,6 +343,46 @@ describe('Utils', () => { }, output: `{"key":"value"}`, }, + // conditional with true + { + input: '{{#enabled}}window.func("enable", true);{{/enabled}}', + params: { + enabled: true, + }, + output: `window.func("enable", true);`, + }, + // conditional with false + { + input: '{{#enabled}}window.func("enable", true);{{/enabled}}', + params: { + enabled: false, + }, + output: ``, + }, + // conditional with true including variable + { + input: '{{#name}}window.func("setName", {{name}});{{/name}}', + params: { + name: 'James', + }, + output: `window.func("setName", "James");`, + }, + // conditional with false including variable + { + input: '{{#name}}window.func("setName", {{name}});{{/name}}', + params: { + name: null, + }, + output: ``, + }, + // conditional with too many braces (do not do that!) + { + input: '{{{#name}}}window.func("setName", {{name}});{{{/name}}}', + params: { + name: 'James', + }, + output: `{}window.func("setName", "James");{}`, + }, ]; it.each(inputs)( diff --git a/src/utils/index.ts b/src/utils/index.ts index 2c06f2b..78ac1a2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -60,7 +60,19 @@ export function formatCode( args?: Inputs, optionalParams?: Inputs, ) { - return code.replace(/{{(.*?)}}/g, (match) => { + // Conditionals. + code = code.replace( + /{{#([^{}]+?)}}(.*){{\/\1}}/g, + (match, name, innerCode) => { + if (args?.[name] || optionalParams?.[name]) { + return innerCode; + } + return ''; + }, + ); + + // Variable replacements. + return code.replace(/{{[^#/](.*?)}}/g, (match) => { const name = match.split(/{{|}}/).filter(Boolean)[0]; return JSON.stringify(args?.[name] ?? optionalParams?.[name] ?? undefined); diff --git a/tests/phpunit/tests/Data/ThirdPartyDataFormatterTest.php b/tests/phpunit/tests/Data/ThirdPartyDataFormatterTest.php index 749bf84..c901b7c 100644 --- a/tests/phpunit/tests/Data/ThirdPartyDataFormatterTest.php +++ b/tests/phpunit/tests/Data/ThirdPartyDataFormatterTest.php @@ -372,4 +372,44 @@ public function testFormatCodeWithArgsIncorrectOrderAndTooMany() $code ); } + + /** + * @dataProvider dataFormatCodeWithConditionals + */ + public function testFormatCodeWithConditionals(string $input, array $params, string $expected) + { + $code = ThirdPartyDataFormatter::formatCode($input, $params); + $this->assertSame($expected, $code); + } + + public function dataFormatCodeWithConditionals(): array + { + return [ + 'true' => [ + '{{#enabled}}window.func("enable", true);{{/enabled}}', + [ 'enabled' => true ], + 'window.func("enable", true);', + ], + 'false' => [ + '{{#enabled}}window.func("enable", true);{{/enabled}}', + [ 'enabled' => false ], + '', + ], + 'true with variable' => [ + '{{#name}}window.func("setName", {{name}});{{/name}}', + [ 'name' => 'James' ], + 'window.func("setName", "James");', + ], + 'false with variable' => [ + '{{#name}}window.func("setName", {{name}});{{/name}}', + [ 'name' => null ], + '', + ], + 'too many braces' => [ + '{{{#name}}}window.func("setName", {{name}});{{{/name}}}', + [ 'name' => 'James' ], + '{}window.func("setName", "James");{}', + ], + ]; + } } diff --git a/tests/phpunit/tests/ThirdParties/GoogleAnalyticsTest.php b/tests/phpunit/tests/ThirdParties/GoogleAnalyticsTest.php index fb2bdc4..a8c801d 100644 --- a/tests/phpunit/tests/ThirdParties/GoogleAnalyticsTest.php +++ b/tests/phpunit/tests/ThirdParties/GoogleAnalyticsTest.php @@ -42,7 +42,6 @@ static function ($script) { public function dataOutput(): array { - $consentDefault = '{"ad_user_data":"denied","ad_personalization":"denied","ad_storage":"denied","analytics_storage":"denied","wait_for_update":500}'; return [ 'basic example' => [ [ 'id' => 'G-12345678' ], @@ -58,7 +57,7 @@ public function dataOutput(): array 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, 'location' => ThirdPartyScriptData::LOCATION_HEAD, 'action' => ThirdPartyScriptData::ACTION_APPEND, - 'code' => "window[\"dataLayer\"]=window[\"dataLayer\"]||[];window['gtag-'+\"dataLayer\"]=function (){window[\"dataLayer\"].push(arguments);};window['gtag-'+\"dataLayer\"]('consent', \"default\", {$consentDefault});window['gtag-'+\"dataLayer\"]('js',new Date());window['gtag-'+\"dataLayer\"]('config',\"G-12345678\")", + 'code' => "window[\"dataLayer\"]=window[\"dataLayer\"]||[];window['gtag-'+\"dataLayer\"]=function (){window[\"dataLayer\"].push(arguments);};window['gtag-'+\"dataLayer\"]('js',new Date());window['gtag-'+\"dataLayer\"]('config',\"G-12345678\")", 'key' => 'setup', ], ], @@ -80,7 +79,63 @@ public function dataOutput(): array 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, 'location' => ThirdPartyScriptData::LOCATION_HEAD, 'action' => ThirdPartyScriptData::ACTION_APPEND, - 'code' => "window[\"myDataLayer1\"]=window[\"myDataLayer1\"]||[];window['gtag-'+\"myDataLayer1\"]=function (){window[\"myDataLayer1\"].push(arguments);};window['gtag-'+\"myDataLayer1\"]('consent', \"default\", {$consentDefault});window['gtag-'+\"myDataLayer1\"]('js',new Date());window['gtag-'+\"myDataLayer1\"]('config',\"G-13579\")", + 'code' => "window[\"myDataLayer1\"]=window[\"myDataLayer1\"]||[];window['gtag-'+\"myDataLayer1\"]=function (){window[\"myDataLayer1\"].push(arguments);};window['gtag-'+\"myDataLayer1\"]('js',new Date());window['gtag-'+\"myDataLayer1\"]('config',\"G-13579\")", + 'key' => 'setup', + ], + ], + ], + 'with default consent' => [ + [ + 'id' => 'G-12345678', + 'consentValues' => [ + 'ad_user_data' => 'denied', + 'ad_personalization' => 'denied', + 'ad_storage' => 'denied', + 'analytics_storage' => 'denied', + 'wait_for_update' => 500, + ], + ], + [ + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'url' => 'https://www.googletagmanager.com/gtag/js?id=G-12345678', + 'key' => 'gtag', + ], + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'code' => "window[\"dataLayer\"]=window[\"dataLayer\"]||[];window['gtag-'+\"dataLayer\"]=function (){window[\"dataLayer\"].push(arguments);};window['gtag-'+\"dataLayer\"]('consent', \"default\", {\"ad_user_data\":\"denied\",\"ad_personalization\":\"denied\",\"ad_storage\":\"denied\",\"analytics_storage\":\"denied\",\"wait_for_update\":500});window['gtag-'+\"dataLayer\"]('js',new Date());window['gtag-'+\"dataLayer\"]('config',\"G-12345678\")", + 'key' => 'setup', + ], + ], + ], + 'with consent update' => [ + [ + 'id' => 'G-12345678', + 'consentType' => 'update', + 'consentValues' => [ + 'ad_user_data' => 'granted', + 'ad_personalization' => 'granted', + 'ad_storage' => 'granted', + 'analytics_storage' => 'granted', + ], + ], + [ + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'url' => 'https://www.googletagmanager.com/gtag/js?id=G-12345678', + 'key' => 'gtag', + ], + [ + 'strategy' => ThirdPartyScriptData::STRATEGY_WORKER, + 'location' => ThirdPartyScriptData::LOCATION_HEAD, + 'action' => ThirdPartyScriptData::ACTION_APPEND, + 'code' => "window[\"dataLayer\"]=window[\"dataLayer\"]||[];window['gtag-'+\"dataLayer\"]=function (){window[\"dataLayer\"].push(arguments);};window['gtag-'+\"dataLayer\"]('consent', \"update\", {\"ad_user_data\":\"granted\",\"ad_personalization\":\"granted\",\"ad_storage\":\"granted\",\"analytics_storage\":\"granted\"});window['gtag-'+\"dataLayer\"]('js',new Date());window['gtag-'+\"dataLayer\"]('config',\"G-12345678\")", 'key' => 'setup', ], ],