Commit realizado el 12:13:52 08-04-2024

This commit is contained in:
Pagina Web Monito
2024-04-08 12:13:55 -04:00
commit 0c33094de9
7815 changed files with 1365694 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
<?php
/**
* Class Canonical_Sanitizer.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP;
use DOMElement;
use DOMNode;
use DOMNodeList;
use Google\Web_Stories_Dependencies\AMP_Base_Sanitizer;
use Google\Web_Stories_Dependencies\AmpProject\Html\Attribute;
use Google\Web_Stories_Dependencies\AmpProject\Html\Tag;
/**
* Canonical sanitizer class.
*
* Ensures link[rel=canonical] exists on the page,
* as some plugins might have removed it in the meantime
* or the user might be viewing a draft.
*
* Only needed when the AMP plugin is not active, as the plugin
* handles that already.
*
* @since 1.1.0
*
* @link https://github.com/googleforcreators/web-stories-wp/issues/4193
* @link https://github.com/googleforcreators/web-stories-wp/pull/8169
* @see \AMP_Theme_Support::ensure_required_markup()
*/
class Canonical_Sanitizer extends AMP_Base_Sanitizer {
/**
* Sanitize the HTML contained in the DOMDocument received by the constructor.
*
* @since 1.1.0
*/
public function sanitize(): void {
$canonical_url = $this->args['canonical_url'];
$query = $this->dom->xpath->query( '//link[@rel="canonical"]', $this->dom->head );
// Remove any duplicate items first.
if ( $query instanceof DOMNodeList && $query->length > 1 ) {
for ( $i = 1; $i < $query->length; $i++ ) {
$node = $query->item( $i );
if ( $node ) {
$this->dom->head->removeChild( $node );
}
}
}
/**
* DOMElement
*
* @var DOMElement|DOMNode $rel_canonical
*/
$rel_canonical = $query instanceof DOMNodeList ? $query->item( 0 ) : null;
if ( ! $rel_canonical instanceof DOMElement ) {
$rel_canonical = $this->dom->createElement( Tag::LINK );
if ( $rel_canonical instanceof DOMElement ) {
$rel_canonical->setAttribute( Attribute::REL, Attribute::REL_CANONICAL );
$this->dom->head->appendChild( $rel_canonical );
}
}
if ( $rel_canonical instanceof DOMElement ) {
// Ensure link[rel=canonical] has a non-empty href attribute.
if ( empty( $rel_canonical->getAttribute( Attribute::HREF ) ) ) {
$rel_canonical->setAttribute( Attribute::HREF, (string) $canonical_url );
}
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* Class AMP_Story_Sanitizer.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP\Integration;
use AMP_Base_Sanitizer;
use Google\Web_Stories\AMP\Traits\Sanitization_Utils;
/**
* AMP Story sanitizer.
*
* Like Story_Sanitizer, but for use with the AMP WordPress plugin.
*
* @since 1.1.0
*
* @see Story_Sanitizer
*/
class AMP_Story_Sanitizer extends AMP_Base_Sanitizer {
use Sanitization_Utils;
/**
* Sanitize the HTML contained in the DOMDocument received by the constructor.
*
* @since 1.1.0
*/
public function sanitize(): void {
$this->transform_html_start_tag( $this->dom );
$this->transform_a_tags( $this->dom );
$this->use_semantic_heading_tags( $this->dom );
$this->add_publisher_logo( $this->dom, $this->args['publisher_logo'] );
$this->add_publisher( $this->dom, $this->args['publisher'] );
$this->add_poster_images( $this->dom, $this->args['poster_images'] );
// This needs to be called before use_semantic_heading_tags() because it relies on the style attribute.
$this->deduplicate_inline_styles( $this->dom );
$this->add_video_cache( $this->dom, $this->args['video_cache'] );
$this->remove_blob_urls( $this->dom );
$this->sanitize_srcset( $this->dom );
$this->sanitize_amp_story_page_outlink( $this->dom );
$this->remove_page_template_placeholder_images( $this->dom );
$this->sanitize_title_and_meta_description( $this->dom, $this->args['title_tag'], $this->args['description'] );
}
}

View File

@@ -0,0 +1,124 @@
<?php
/**
* Class Meta_Sanitizer.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP;
use Google\Web_Stories_Dependencies\AMP_Meta_Sanitizer;
use Google\Web_Stories_Dependencies\AmpProject\Html\Attribute;
use Google\Web_Stories_Dependencies\AmpProject\Html\Tag;
/**
* Meta sanitizer.
*
* Sanitizes meta tags found in the header.
*
* This version avoids using amp_get_boilerplate_stylesheets().
*
* @since 1.1.0
*
* @see amp_get_boilerplate_stylesheets()
* @see AMP_Meta_Sanitizer
*/
class Meta_Sanitizer extends AMP_Meta_Sanitizer {
/**
* Always ensure we have a style[amp-boilerplate] and a noscript>style[amp-boilerplate].
*
* The AMP boilerplate ({@link https://amp.dev/documentation/guides-and-tutorials/stories/learn/spec/amp-boilerplate}) styles should appear at the end of the head:
* "Finally, specify the AMP boilerplate code. By putting the boilerplate code last, it prevents custom styles from
* accidentally overriding the boilerplate css rules."
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.1.0
*
* @link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/#optimize-the-amp-runtime-loading
*/
protected function ensure_boilerplate_is_present(): void {
$style = null;
$styles = $this->dom->xpath->query( './style[ @amp-boilerplate ]', $this->dom->head );
if ( $styles ) {
$style = $styles->item( 0 );
}
if ( ! $style ) {
$style = $this->dom->createElement( Tag::STYLE );
if ( $style ) {
$style->setAttribute( Attribute::AMP_BOILERPLATE, '' );
$style->appendChild( $this->dom->createTextNode( $this->get_boilerplate_stylesheets()[0] ) );
}
} elseif ( $style->parentNode ) {
$style->parentNode->removeChild( $style ); // So we can move it.
}
if ( $style ) {
$this->dom->head->appendChild( $style );
}
$noscript = null;
$noscripts = $this->dom->xpath->query( './noscript[ style[ @amp-boilerplate ] ]', $this->dom->head );
if ( $noscripts ) {
$noscript = $noscripts->item( 0 );
}
if ( ! $noscript ) {
$noscript = $this->dom->createElement( Tag::NOSCRIPT );
$style = $this->dom->createElement( Tag::STYLE );
if ( $style && $noscript ) {
$style->setAttribute( Attribute::AMP_BOILERPLATE, '' );
$style->appendChild( $this->dom->createTextNode( $this->get_boilerplate_stylesheets()[1] ) );
$noscript->appendChild( $style );
}
} elseif ( $noscript->parentNode ) {
$noscript->parentNode->removeChild( $noscript ); // So we can move it.
}
if ( $noscript ) {
$this->dom->head->appendChild( $noscript );
}
}
/**
* Get AMP boilerplate stylesheets.
*
* Clone of amp_get_boilerplate_stylesheets().
*
* @since 1.1.0
*
* @link https://www.ampproject.org/docs/reference/spec#boilerplate
* @see amp_get_boilerplate_stylesheets()
*
* @return string[] Stylesheets, where first is contained in style[amp-boilerplate] and the second in noscript>style[amp-boilerplate].
*/
protected function get_boilerplate_stylesheets(): array {
return [
'body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}',
'body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}',
];
}
}

View File

@@ -0,0 +1,186 @@
<?php
/**
* Class Optimization
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP;
use Google\Web_Stories_Dependencies\AmpProject\AmpWP\RemoteRequest\CachedRemoteGetRequest;
use Google\Web_Stories_Dependencies\AmpProject\AmpWP\RemoteRequest\WpHttpRemoteGetRequest;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Configuration;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Configuration\AmpStoryCssOptimizerConfiguration;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\DefaultConfiguration;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Error;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\ErrorCollection;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\LocalFallback;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\TransformationEngine;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer\AmpRuntimeCss;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer\AmpStoryCssOptimizer;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer\MinifyHtml;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer\OptimizeAmpBind;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer\OptimizeHeroImages;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer\RewriteAmpUrls;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer\ServerSideRendering;
use Google\Web_Stories_Dependencies\AmpProject\Optimizer\Transformer\TransformedIdentifier;
use Google\Web_Stories_Dependencies\AmpProject\RemoteRequest\FallbackRemoteGetRequest;
use Google\Web_Stories_Dependencies\AmpProject\RemoteRequest\FilesystemRemoteGetRequest;
/**
* Optimization class.
*
* @since 1.1.0
*/
class Optimization {
/**
* Optimizes a document.
*
* @since 1.1.0
*
* @param Document $document Document instance.
*/
public function optimize_document( Document $document ): void {
$errors = new ErrorCollection();
$this->get_optimizer()->optimizeDom( $document, $errors );
if ( \count( $errors ) > 0 ) {
/**
* Error list.
*
* @var Error[] $errors_array Error list.
*/
$errors_array = iterator_to_array( $errors );
$error_messages = array_filter(
array_map(
static function ( Error $error ) {
// Hidden because amp-story is a render-delaying extension.
if ( 'CannotRemoveBoilerplate' === $error->getCode() ) {
return '';
}
return ' - ' . $error->getCode() . ': ' . $error->getMessage();
},
$errors_array
)
);
if ( ! empty( $error_messages ) ) {
$document->head->appendChild(
$document->createComment( "\n" . __( 'AMP optimization could not be completed due to the following:', 'web-stories' ) . "\n" . implode( "\n", $error_messages ) . "\n" )
);
}
}
}
/**
* Optimizer instance to use.
*
* @since 1.1.0
*
* @link https://github.com/ampproject/amp-wp/blob/8856284d90fc8558c30acc029becd352ae26e4e1/includes/class-amp-theme-support.php#L2235-L2255
* @see AMP_Theme_Support::get_optimizer
*
* @return TransformationEngine Optimizer transformation engine to use.
*/
private function get_optimizer(): TransformationEngine {
$configuration = self::get_optimizer_configuration();
$fallback_remote_request_pipeline = new FallbackRemoteGetRequest(
new WpHttpRemoteGetRequest(),
new FilesystemRemoteGetRequest( LocalFallback::getMappings() )
);
$cached_remote_request = new CachedRemoteGetRequest( $fallback_remote_request_pipeline, WEEK_IN_SECONDS );
return new TransformationEngine(
$configuration,
$cached_remote_request
);
}
/**
* Get the AmpProject\Optimizer configuration object to use.
*
* @since 1.1.0
*
* @link https://github.com/ampproject/amp-wp/blob/5405daa38e65f0ec16ffc920014d0110b03ee773/src/Optimizer/AmpWPConfiguration.php#L43-L78
* @see AmpWPConfiguration::apply_filters()
*
* @return Configuration Optimizer configuration to use.
*/
private static function get_optimizer_configuration(): Configuration {
$transformers = Configuration::DEFAULT_TRANSFORMERS;
$transformers[] = AmpStoryCssOptimizer::class;
/**
* Filter whether the AMP Optimizer should use server-side rendering or not.
*
* @since 1.1.0
*
* @param bool $enable_ssr Whether the AMP Optimizer should use server-side rendering or not.
*/
$enable_ssr = apply_filters( 'web_stories_enable_ssr', true );
// In debugging mode, we don't use server-side rendering, as it further obfuscates the HTML markup.
if ( ! $enable_ssr ) {
$transformers = array_diff(
$transformers,
[
AmpRuntimeCss::class,
OptimizeHeroImages::class,
OptimizeAmpBind::class,
RewriteAmpUrls::class,
ServerSideRendering::class,
TransformedIdentifier::class,
AmpStoryCssOptimizer::class,
]
);
}
$configuration = [
Configuration::KEY_TRANSFORMERS => $transformers,
AmpStoryCssOptimizer::class => [
AmpStoryCssOptimizerConfiguration::OPTIMIZE_AMP_STORY => true,
],
MinifyHtml::class => [
// Prevents issues with rounding floats, relevant for things like shopping (product prices).
Configuration\MinifyHtmlConfiguration::MINIFY_JSON => false,
],
];
/**
* Filter the configuration to be used for the AMP Optimizer.
*
* @since 1.1.0
*
* @param array $configuration Associative array of configuration data.
*/
$configuration = apply_filters( 'web_stories_amp_optimizer_config', $configuration );
return new DefaultConfiguration( $configuration );
}
}

View File

@@ -0,0 +1,243 @@
<?php
/**
* Class Output_Buffer
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP;
use Google\Web_Stories\Context;
use Google\Web_Stories\Exception\SanitizationException;
use Google\Web_Stories\Infrastructure\Conditional;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
use Throwable;
/**
* Output buffer class.
*
* Largely copied from AMP_Theme_Support.
*
* @since 1.10.0
*
* @see \AMP_Theme_Support
*/
class Output_Buffer extends Service_Base implements Conditional {
/**
* Whether output buffering has started.
*/
protected bool $is_output_buffering = false;
/**
* Sanitization instance.
*
* @var Sanitization Sanitization instance.
*/
private Sanitization $sanitization;
/**
* Optimization instance.
*
* @var Optimization Optimization instance.
*/
private Optimization $optimization;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Output_Buffer constructor.
*
* @since 1.10.0
*
* @param Sanitization $sanitization Sanitization instance.
* @param Optimization $optimization Optimization instance.
* @param Context $context Context instance.
*/
public function __construct( Sanitization $sanitization, Optimization $optimization, Context $context ) {
$this->sanitization = $sanitization;
$this->optimization = $optimization;
$this->context = $context;
}
/**
* Runs on instantiation.
*
* @since 1.10.0
*/
public function register(): void {
/*
* Start output buffering at very low priority for sake of plugins and themes that use template_redirect
* instead of template_include.
*/
$this->start_output_buffering();
}
/**
* Get the action to use for registering the service.
*
* @since 1.10.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'template_redirect';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.10.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return PHP_INT_MIN;
}
/**
* Check whether the conditional object is currently needed.
*
* If the AMP plugin is installed and available in a version >= than ours,
* all sanitization and optimization should be delegated to the AMP plugin.
* But ONLY if AMP logic has not been disabled through any of its available filters.
*
* @since 1.10.0
*
* @return bool Whether the conditional object is needed.
*/
public static function is_needed(): bool {
$current_post = get_post();
$has_old_amp_version = ! \defined( '\AMP__VERSION' ) || ( \defined( '\AMP__VERSION' ) && version_compare( \AMP__VERSION, WEBSTORIES_AMP_VERSION, '<' ) );
$amp_available = \function_exists( 'amp_is_available' ) && amp_is_available();
$amp_enabled = \function_exists( 'amp_is_enabled' ) && amp_is_enabled(); // Technically an internal method.
$amp_initialized = did_action( 'amp_init' ) > 0;
$amp_supported_post = \function_exists( 'amp_is_post_supported' ) && amp_is_post_supported( $current_post->ID ?? 0 );
return $has_old_amp_version || ! $amp_available || ! $amp_enabled || ! $amp_initialized || ! $amp_supported_post;
}
/**
* Start output buffering.
*
* @since 1.10.0
*
* @see Output_Buffer::finish_output_buffering()
*/
public function start_output_buffering(): void {
if ( ! $this->context->is_web_story() ) {
return;
}
ob_start( [ $this, 'finish_output_buffering' ] );
$this->is_output_buffering = true;
}
/**
* Determine whether output buffering has started.
*
* @since 1.10.0
*
* @see Output_Buffer::start_output_buffering()
* @see Output_Buffer::finish_output_buffering()
*
* @return bool Whether output buffering has started.
*/
public function is_output_buffering(): bool {
return $this->is_output_buffering;
}
/**
* Finish output buffering.
*
* @since 1.10.0
*
* @see Output_Buffer::start_output_buffering()
*
* @param string $response Buffered Response.
* @return string Processed Response.
*/
public function finish_output_buffering( string $response ): string {
$this->is_output_buffering = false;
try {
$response = $this->prepare_response( $response );
} catch ( \Error $error ) { // Only PHP 7+.
$response = $this->render_error_page( $error );
} catch ( \Exception $exception ) {
$response = $this->render_error_page( $exception );
}
return $response;
}
/**
* Process response to ensure AMP validity.
*
* @since 1.10.0
*
* @param string $response HTML document response. By default it expects a complete document.
* @return string AMP document response.
*/
public function prepare_response( string $response ): string {
// Enforce UTF-8 encoding as it is a requirement for AMP.
if ( ! headers_sent() ) {
header( 'Content-Type: text/html; charset=utf-8' );
}
$dom = Document::fromHtml( $response );
if ( ! $dom instanceof Document ) {
return $this->render_error_page( SanitizationException::from_document_parse_error() );
}
$this->sanitization->sanitize_document( $dom );
$this->optimization->optimize_document( $dom );
return $dom->saveHTML();
}
/**
* Render error page.
*
* @since 1.10.0
*
* @param Throwable $throwable Exception or (as of PHP7) Error.
* @return string Error page.
*/
private function render_error_page( Throwable $throwable ): string {
return esc_html__( 'There was an error generating the web story, probably because of a server misconfiguration. Try contacting your hosting provider or open a new support request.', 'web-stories' ) .
"\n" .
"\n" .
// translators: 1: error message. 2: location.
sprintf( esc_html__( 'Error message: %1$s (%2$s)', 'web-stories' ), $throwable->getMessage(), $throwable->getFile() . ':' . $throwable->getLine() );
}
}

View File

@@ -0,0 +1,547 @@
<?php
/**
* Class Sanitization
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP;
use DOMElement;
use DOMNode;
use Google\Web_Stories\Model\Story;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories_Dependencies\AMP_Allowed_Tags_Generated;
use Google\Web_Stories_Dependencies\AMP_Content_Sanitizer;
use Google\Web_Stories_Dependencies\AMP_Dev_Mode_Sanitizer;
use Google\Web_Stories_Dependencies\AMP_DOM_Utils;
use Google\Web_Stories_Dependencies\AMP_Layout_Sanitizer;
use Google\Web_Stories_Dependencies\AMP_Script_Sanitizer;
use Google\Web_Stories_Dependencies\AMP_Style_Sanitizer;
use Google\Web_Stories_Dependencies\AmpProject\Amp;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
use Google\Web_Stories_Dependencies\AmpProject\Extension;
use Google\Web_Stories_Dependencies\AmpProject\Html\Attribute;
use Google\Web_Stories_Dependencies\AmpProject\Html\Tag;
/**
* Sanitization class.
*
* Largely copied from AMP_Theme_Support.
*
* @since 1.1.0
*
* @see \AMP_Theme_Support
*/
class Sanitization {
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Analytics constructor.
*
* @since 1.12.0
*
* @param Settings $settings Settings instance.
* @return void
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Sanitizes a document.
*
* @since 1.1.0
*
* @param Document $document Document instance.
*/
public function sanitize_document( Document $document ): void {
$sanitizers = $this->get_sanitizers();
$result = AMP_Content_Sanitizer::sanitize_document( $document, $sanitizers, [] );
$this->ensure_required_markup( $document, $result['scripts'] );
}
/**
* Validation error callback.
*
* @since 1.1.0
*
* @see AMP_Validation_Error_Taxonomy::get_validation_error_sanitization
*
* @param array{code: string} $error Error info, especially code.
* @param array{node?: DOMElement|DOMNode} $data Additional data, including the node.
* @return bool Whether the validation error should be sanitized.
*/
public function validation_error_callback( array $error, array $data = [] ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
/**
* Filters whether the validation error should be sanitized.
*
* Returning true this indicates that the validation error is acceptable
* and should not be considered a blocker to render AMP. Returning null
* means that the default status should be used.
*
* Note that the $node is not passed here to ensure that the filter can be
* applied on validation errors that have been stored. Likewise, the $sources
* are also omitted because these are only available during an explicit
* validation request and so they are not suitable for plugins to vary
* sanitization by.
*
* @since 1.1.0
*
* @see AMP_Validation_Manager::is_sanitization_auto_accepted() Which controls whether an error is initially accepted or rejected for sanitization.
*
* @param bool $sanitized Whether the validation error should be sanitized.
* @param array $error Validation error being sanitized.
*/
return apply_filters( 'web_stories_amp_validation_error_sanitized', true, $error );
}
/**
* Adds missing scripts.
*
* @SuppressWarnings(PHPMD)
*
* @since 1.1.0
*
* @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/class-amp-theme-support.php#L1381-L1594
* @see \AMP_Theme_Support::ensure_required_markup
*
* @param Document $document Document instance.
* @param array<string,string> $scripts List of found scripts.
*/
protected function ensure_required_markup( Document $document, array $scripts ): void { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
/**
* Link elements.
*
* @var array{preconnect: \DOMElement[]|null,dns-prefetch: \DOMElement[]|null,preload: \DOMElement[]|null, prerender: \DOMElement[]|null, prefetch: \DOMElement[]|null }
*/
$links = [
Attribute::REL_PRECONNECT => [
// Include preconnect link for AMP CDN for browsers that don't support preload.
AMP_DOM_Utils::create_node(
$document,
Tag::LINK,
[
Attribute::REL => Attribute::REL_PRECONNECT,
Attribute::HREF => 'https://cdn.ampproject.org',
]
),
],
];
// Obtain the existing AMP scripts.
$amp_scripts = [];
$ordered_scripts = [];
$head_scripts = [];
$runtime_src = 'https://cdn.ampproject.org/v0.js';
/**
* Script element.
*
* @var DOMElement $script
*/
foreach ( $document->head->getElementsByTagName( Tag::SCRIPT ) as $script ) {
$head_scripts[] = $script;
}
foreach ( $head_scripts as $script ) {
$src = $script->getAttribute( Attribute::SRC );
if ( ! $src || ! str_starts_with( $src, 'https://cdn.ampproject.org/' ) ) {
continue;
}
if ( 'v0.js' === substr( $src, - \strlen( 'v0.js' ) ) ) {
$amp_scripts[ Amp::RUNTIME ] = $script;
} elseif ( $script->hasAttribute( Attribute::CUSTOM_ELEMENT ) ) {
$amp_scripts[ $script->getAttribute( Attribute::CUSTOM_ELEMENT ) ] = $script;
} elseif ( $script->hasAttribute( Attribute::CUSTOM_TEMPLATE ) ) {
$amp_scripts[ $script->getAttribute( Attribute::CUSTOM_TEMPLATE ) ] = $script;
}
// It will be added back further down.
$document->head->removeChild( $script );
}
$specs = $this->get_extension_sources();
// Create scripts for any components discovered from output buffering that are missing.
foreach ( array_diff( array_keys( $scripts ), array_keys( $amp_scripts ) ) as $missing_script_handle ) {
$attrs = [
Attribute::SRC => $specs[ $missing_script_handle ],
Attribute::ASYNC => '',
];
if ( Extension::MUSTACHE === $missing_script_handle ) {
$attrs[ Attribute::CUSTOM_TEMPLATE ] = (string) $missing_script_handle;
} else {
$attrs[ Attribute::CUSTOM_ELEMENT ] = (string) $missing_script_handle;
}
$amp_scripts[ $missing_script_handle ] = AMP_DOM_Utils::create_node( $document, Tag::SCRIPT, $attrs );
}
// Remove scripts that had already been added but couldn't be detected from output buffering.
$extension_specs = AMP_Allowed_Tags_Generated::get_extension_specs();
$superfluous_script_handles = array_diff(
array_keys( $amp_scripts ),
[ ...array_keys( $scripts ), Amp::RUNTIME ]
);
foreach ( $superfluous_script_handles as $superfluous_script_handle ) {
if ( ! empty( $extension_specs[ $superfluous_script_handle ]['requires_usage'] ) ) {
unset( $amp_scripts[ $superfluous_script_handle ] );
}
}
/* phpcs:ignore Squiz.PHP.CommentedOutCode.Found
*
* "2. Next, preload the AMP runtime v0.js <script> tag with <link as=script href=https://cdn.ampproject.org/v0.js rel=preload>.
* The AMP runtime should start downloading as soon as possible because the AMP boilerplate hides the document via body { visibility:hidden }
* until the AMP runtime has loaded. Preloading the AMP runtime tells the browser to download the script with a higher priority."
* {@link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/ Optimize the AMP Runtime loading}
*/
$prioritized_preloads = [];
if ( ! isset( $links[ Attribute::REL_PRELOAD ] ) ) {
$links[ Attribute::REL_PRELOAD ] = [];
}
$prioritized_preloads[] = AMP_DOM_Utils::create_node(
$document,
Tag::LINK,
[
Attribute::REL => Attribute::REL_PRELOAD,
'as' => Tag::SCRIPT,
Attribute::HREF => $runtime_src,
]
);
/*
* "3. If your page includes render-delaying extensions (e.g., amp-experiment, amp-dynamic-css-classes, amp-story),
* preload those extensions as they're required by the AMP runtime for rendering the page."
*/
$amp_script_handles = array_keys( $amp_scripts );
foreach ( array_intersect( Amp::RENDER_DELAYING_EXTENSIONS, $amp_script_handles ) as $script_handle ) {
if ( ! \in_array( $script_handle, Amp::RENDER_DELAYING_EXTENSIONS, true ) ) {
continue;
}
/**
* AMP script element.
*
* @var DOMElement $script_element
*/
$script_element = $amp_scripts[ $script_handle ];
$prioritized_preloads[] = AMP_DOM_Utils::create_node(
$document,
Tag::LINK,
[
Attribute::REL => Attribute::REL_PRELOAD,
'as' => Tag::SCRIPT,
Attribute::HREF => $script_element->getAttribute( Attribute::SRC ),
]
);
}
$links[ Attribute::REL_PRELOAD ] = array_merge( $prioritized_preloads, $links[ Attribute::REL_PRELOAD ] );
// Store the last meta tag as the previous node to append to.
$meta_tags = $document->head->getElementsByTagName( Tag::META );
$previous_node = $meta_tags->length > 0 ? $meta_tags->item( $meta_tags->length - 1 ) : $document->head->firstChild;
/*
* "4. Use preconnect to speedup the connection to other origin where the full resource URL is not known ahead of time,
* for example, when using Google Fonts."
*
* Note that \AMP_Style_Sanitizer::process_link_element() will ensure preconnect links for Google Fonts are present.
*/
$link_relations = [ Attribute::REL_PRECONNECT, Attribute::REL_DNS_PREFETCH, Attribute::REL_PRELOAD, Attribute::REL_PRERENDER, Attribute::REL_PREFETCH ];
foreach ( $link_relations as $rel ) {
if ( ! isset( $links[ $rel ] ) ) {
continue;
}
/**
* Link node.
*
* @var DOMElement $link
*/
foreach ( $links[ $rel ] as $link ) {
if ( $link->parentNode ) {
$link->parentNode->removeChild( $link ); // So we can move it.
}
if ( $previous_node && $previous_node->nextSibling ) {
$document->head->insertBefore( $link, $previous_node->nextSibling );
$previous_node = $link;
}
}
}
// "5. Load the AMP runtime."
if ( isset( $amp_scripts[ Amp::RUNTIME ] ) ) {
$ordered_scripts[ Amp::RUNTIME ] = $amp_scripts[ Amp::RUNTIME ];
unset( $amp_scripts[ Amp::RUNTIME ] );
} else {
$script = $document->createElement( Tag::SCRIPT );
if ( $script ) {
$script->setAttribute( Attribute::ASYNC, '' );
$script->setAttribute( Attribute::SRC, $runtime_src );
$ordered_scripts[ Amp::RUNTIME ] = $script;
}
}
/*
* "6. Specify the <script> tags for render-delaying extensions (e.g., amp-experiment amp-dynamic-css-classes and amp-story"
*
* {@link https://amp.dev/documentation/guides-and-tutorials/optimize-and-measure/optimize_amp/ AMP Hosting Guide}
*/
foreach ( Amp::RENDER_DELAYING_EXTENSIONS as $extension ) {
if ( isset( $amp_scripts[ $extension ] ) ) {
$ordered_scripts[ $extension ] = $amp_scripts[ $extension ];
unset( $amp_scripts[ $extension ] );
}
}
/*
* "7. Specify the <script> tags for remaining extensions (e.g., amp-bind ...). These extensions are not render-delaying
* and therefore should not be preloaded as they might take away important bandwidth for the initial render."
*/
ksort( $amp_scripts );
$ordered_scripts = array_merge( $ordered_scripts, $amp_scripts );
/**
* Script element.
*
* @var DOMElement $ordered_script
*/
foreach ( $ordered_scripts as $ordered_script ) {
if ( $previous_node && $previous_node->nextSibling ) {
$document->head->insertBefore( $ordered_script, $previous_node->nextSibling );
$previous_node = $ordered_script;
}
}
}
/**
* Returns AMP extension URLs, keyed by extension name.
*
* @since 1.1.0
*
* @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/amp-helper-functions.php#L876-L941
* @see amp_register_default_scripts
*
* @return array<string,string> List of extensions and their URLs.
*/
protected function get_extension_sources(): array {
$specs = [];
// Register all AMP components as defined in the spec.
foreach ( AMP_Allowed_Tags_Generated::get_extension_specs() as $extension_name => $extension_spec ) {
$src = sprintf(
'https://cdn.ampproject.org/v0/%s-%s.js',
$extension_name,
$extension_spec['latest']
);
$specs[ $extension_name ] = $src;
}
return $specs;
}
/**
* Determine whether AMP dev mode is enabled.
*
* When enabled, the <html> element will get the data-ampdevmode attribute and the plugin will add the same attribute
* to elements associated with the admin bar and other elements that are provided by the `amp_dev_mode_element_xpaths`
* filter.
*
* @since 1.1.0
*
* @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/amp-helper-functions.php#L1296-L1330
* @see amp_is_dev_mode
*
* @return bool Whether AMP dev mode is enabled.
*/
protected function is_amp_dev_mode(): bool {
// For the few sites that forcibly show the admin bar even when the user is logged out, only enable dev
// mode if the user is actually logged in. This prevents the dev mode from being served to crawlers
// when they index the AMP version.
$dev_mode_enabled = ( is_admin_bar_showing() && is_user_logged_in() );
/**
* Filters whether AMP dev mode is enabled.
*
* When enabled, the data-ampdevmode attribute will be added to the document element and it will allow the
* attributes to be added to the admin bar. It will also add the attribute to all elements which match the
* queries for the expressions returned by the 'web_stories_amp_dev_mode_element_xpaths' filter.
*
* @since 1.1.0
*
* @param bool $dev_mode_enabled Whether AMP dev mode is enabled.
*/
return apply_filters( 'web_stories_amp_dev_mode_enabled', $dev_mode_enabled );
}
/**
* Returns a list of sanitizers to use.
*
* This is replica of amp_get_content_sanitizers() to avoid
* loading amp-helper-functions.php due to side-effects like
* accessing options from the database, requiring AMP__VERSION,
* and causing conflicts with our own amp_is_request() compat shim.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.1.0
*
* @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/amp-helper-functions.php#L1332-L1521
* @link https://github.com/ampproject/amp-wp/blob/2.1.3/includes/validation/class-amp-validation-manager.php#L1691-L1730
* @see amp_get_content_sanitizers
* @see AMP_Validation_Manager::filter_sanitizer_args
*
* @return array<string,array<string,bool|string[]|string>> Sanitizers.
*/
protected function get_sanitizers(): array {
// This fallback to get_permalink() ensures that there's a canonical link
// even when previewing drafts.
$canonical_url = wp_get_canonical_url();
if ( ! $canonical_url ) {
$canonical_url = get_permalink();
}
$sanitizers = [
AMP_Script_Sanitizer::class => [],
AMP_Style_Sanitizer::class => [
/*
* @todo Enable by default and allow filtering once AMP_Style_Sanitizer does not call AMP_Options_Manager
* which in turn requires AMP__VERSION to be defined.
*/
'allow_transient_caching' => false,
'use_document_element' => true,
'dynamic_element_selectors' => [
'amp-story-captions',
],
],
Meta_Sanitizer::class => [],
AMP_Layout_Sanitizer::class => [],
Canonical_Sanitizer::class => [
'canonical_url' => $canonical_url,
],
Tag_And_Attribute_Sanitizer::class => [],
];
$post = get_queried_object();
if ( $post instanceof \WP_Post && Story_Post_Type::POST_TYPE_SLUG === get_post_type( $post ) ) {
$video_cache_enabled = (bool) $this->settings->get_setting( $this->settings::SETTING_NAME_VIDEO_CACHE );
$story = new Story();
$story->load_from_post( $post );
$poster_images = [
'poster-portrait-src' => esc_url_raw( $story->get_poster_portrait() ),
];
$sanitizers[ Story_Sanitizer::class ] = [
'publisher_logo' => (string) $story->get_publisher_logo_url(),
'publisher' => $story->get_publisher_name(),
'poster_images' => array_filter( $poster_images ),
'video_cache' => $video_cache_enabled,
'title_tag' => wp_get_document_title(),
'description' => wp_strip_all_tags( get_the_excerpt() ),
];
}
/**
* Filters the content sanitizers.
*
* @since 1.1.0
*
* @param array $sanitizers Sanitizers.
*/
$sanitizers = apply_filters( 'web_stories_amp_sanitizers', $sanitizers );
if ( $this->is_amp_dev_mode() ) {
/**
* Filters the XPath queries for elements that should be enabled for dev mode.
*
* By supplying XPath queries to this filter, the data-ampdevmode attribute will automatically be added to the
* root HTML element as well as to any elements that match the expressions. The attribute is added to the
* elements prior to running any of the sanitizers.
*
* @since 1.3
*
* @param string[] $element_xpaths XPath element queries. Context is the root element.
*/
$dev_mode_xpaths = apply_filters( 'web_stories_amp_dev_mode_element_xpaths', [] );
if ( is_admin_bar_showing() ) {
$dev_mode_xpaths[] = '//*[ @id = "wpadminbar" ]';
$dev_mode_xpaths[] = '//*[ @id = "wpadminbar" ]//*';
$dev_mode_xpaths[] = '//style[ @id = "admin-bar-inline-css" ]';
}
$sanitizers = array_merge(
[
AMP_Dev_Mode_Sanitizer::class => [
'element_xpaths' => $dev_mode_xpaths,
],
],
$sanitizers
);
}
// Force certain sanitizers to be at end.
// AMP_Style_Sanitizer needs to catch any CSS changes from previous sanitizers.
// Tag_And_Attribute_Sanitizer must come at the end to clean up any remaining issues the other sanitizers didn't catch.
foreach ( [
AMP_Layout_Sanitizer::class,
AMP_Style_Sanitizer::class,
Meta_Sanitizer::class,
Tag_And_Attribute_Sanitizer::class,
] as $class_name ) {
if ( isset( $sanitizers[ $class_name ] ) ) {
$sanitizer = $sanitizers[ $class_name ];
unset( $sanitizers[ $class_name ] );
$sanitizers[ $class_name ] = $sanitizer;
}
}
foreach ( $sanitizers as &$sanitizer ) {
$sanitizer['validation_error_callback'] = [ $this, 'validation_error_callback' ];
}
unset( $sanitizer );
return $sanitizers;
}
}

View File

@@ -0,0 +1,65 @@
<?php
/**
* Class Story_Sanitizer.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP;
use Google\Web_Stories\AMP\Traits\Sanitization_Utils;
use Google\Web_Stories_Dependencies\AMP_Base_Sanitizer;
/**
* Story sanitizer.
*
* Sanitizer for Web Stories related features.
*
* @since 1.1.0
*/
class Story_Sanitizer extends AMP_Base_Sanitizer {
use Sanitization_Utils;
/**
* Sanitize the HTML contained in the DOMDocument received by the constructor.
*
* @since 1.1.0
*/
public function sanitize(): void {
$this->transform_html_start_tag( $this->dom );
$this->transform_a_tags( $this->dom );
$this->use_semantic_heading_tags( $this->dom );
$this->add_publisher_logo( $this->dom, $this->args['publisher_logo'] );
$this->add_publisher( $this->dom, $this->args['publisher'] );
$this->add_poster_images( $this->dom, $this->args['poster_images'] );
// This needs to be called before use_semantic_heading_tags() because it relies on the style attribute.
$this->deduplicate_inline_styles( $this->dom );
$this->add_video_cache( $this->dom, $this->args['video_cache'] );
$this->remove_blob_urls( $this->dom );
$this->sanitize_srcset( $this->dom );
$this->sanitize_amp_story_page_outlink( $this->dom );
$this->remove_page_template_placeholder_images( $this->dom );
$this->sanitize_title_and_meta_description( $this->dom, $this->args['title_tag'], $this->args['description'] );
}
}

View File

@@ -0,0 +1,44 @@
<?php
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP;
use Google\Web_Stories_Dependencies\AMP_Tag_And_Attribute_Sanitizer;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
/**
* Strips the tags and attributes from the content that are not allowed by the AMP spec.
*
* @since 1.28.0
*
* @see AMP_Tag_And_Attribute_Sanitizer
*/
class Tag_And_Attribute_Sanitizer extends AMP_Tag_And_Attribute_Sanitizer {
/**
* AMP_Tag_And_Attribute_Sanitizer constructor.
*
* @since 1.28.0
*
* @param Document $dom DOM.
* @param array<string, mixed> $args args.
*/
public function __construct( Document $dom, array $args = [] ) { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
parent::__construct( $dom, $args );
}
}

View File

@@ -0,0 +1,718 @@
<?php
/**
* Trait Sanitization_Utils.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\AMP\Traits;
use AmpProject\Dom\Document as AMP_Document;
use DOMAttr;
use DOMElement;
use DOMNode;
use DOMNodeList;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
/**
* Trait Sanitization_Utils
*
* @since 1.1.0
*/
trait Sanitization_Utils {
/**
* Replaces the HTML start tag to make the language attributes dynamic.
*
* @since 1.1.0
*
* @param Document|AMP_Document $document Document instance.
*/
private function transform_html_start_tag( $document ): void {
$document->html->setAttribute( 'amp', '' );
// See get_language_attributes().
if ( is_rtl() ) {
$document->html->setAttribute( 'dir', 'rtl' );
}
$lang = get_bloginfo( 'language' );
if ( $lang ) {
$document->html->setAttribute( 'lang', $lang );
}
}
/**
* Transform all hyperlinks to ensure they're always valid.
*
* Adds target="_blank" and rel="noreferrer" attributes.
* Does not add rel="noreferrer" for same-origin links.
*
* Removes empty data-tooltip-icon and data-tooltip-text attributes
* to prevent validation issues.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.1.0
*
* @param Document|AMP_Document $document Document instance.
*/
private function transform_a_tags( $document ): void {
$links = $document->getElementsByTagName( 'a' );
/**
* The <a> element
*
* @var DOMElement $link The <a> element
*/
foreach ( $links as $link ) {
$url = $link->getAttribute( 'href' );
$is_relative_link = str_starts_with( $url, '#' );
if ( ! $is_relative_link && ! $link->getAttribute( 'target' ) ) {
$link->setAttribute( 'target', '_blank' );
}
$is_link_to_same_origin = str_starts_with( $url, home_url() ) || $is_relative_link;
$rel = $link->getAttribute( 'rel' );
// Links to the same site should not have "noreferrer".
// Other rel values should not be modified.
// See https://github.com/googleforcreators/web-stories-wp/issues/9494.
$rel = str_replace( 'noreferrer', '', $rel );
if ( ! $is_link_to_same_origin ) {
$rel .= ' noreferrer';
}
if ( empty( $rel ) ) {
$link->removeAttribute( 'rel' );
} else {
$link->setAttribute( 'rel', trim( $rel ) );
}
if ( ! $link->getAttribute( 'data-tooltip-icon' ) ) {
$link->removeAttribute( 'data-tooltip-icon' );
}
if ( ! $link->getAttribute( 'data-tooltip-text' ) ) {
$link->removeAttribute( 'data-tooltip-text' );
}
// Extra hardening to catch links without a proper protocol.
// Matches withProtocol() util in the editor.
if (
! $is_relative_link &&
! str_starts_with( $url, 'http://' ) &&
! str_starts_with( $url, 'https://' ) &&
! str_starts_with( $url, 'tel:' ) &&
! str_starts_with( $url, 'mailto:' )
) {
$link->setAttribute( 'href', 'https://' . $url );
}
}
}
/**
* Transforms all paragraphs in a story to use semantic heading tags if needed.
*
* This logic here mirrors the getTextElementTagNames() function in the editor
* in order to change simple <p> tags into <h1>, <h2> or <h3>, depending on font size.
*
* It is only relevant for older stories that haven't been updated in a while,
* so we bail early if we find any existing headings in the story.
*
* Caveat: if a user forces *all* text elements to be paragraphs,
* this sanitizer would still run and thus turn some paragraphs into headings.
* This seems rather unlikely though.
*
* @since 1.18.0
*
* @link https://github.com/GoogleForCreators/web-stories-wp/issues/12850
*
* @param Document|AMP_Document $document Document instance.
*/
private function use_semantic_heading_tags( $document ): void {
$h1 = $document->getElementsByTagName( 'h1' );
$h2 = $document->getElementsByTagName( 'h2' );
$h3 = $document->getElementsByTagName( 'h3' );
// When a story already contains any headings, we don't need to do anything further.
if ( $h1->count() || $h2->count() || $h3->count() ) {
return;
}
$pages = $document->getElementsByTagName( 'amp-story-page' );
/**
* The <amp-story-page> element
*
* @var DOMElement $page The <amp-story-page> element
*/
foreach ( $pages as $page ) {
$text_elements = $document->xpath->query( './/p[ contains( @class, "text-wrapper" ) ]', $page );
if ( ! $text_elements ) {
return;
}
$this->use_semantic_heading_tags_for_elements( $text_elements );
}
}
/**
* Transforms a list of elements to use semantic heading tags if needed.
*
* @since 1.18.0
*
* @param DOMNodeList $text_elements List of text elements.
*/
private function use_semantic_heading_tags_for_elements( DOMNodeList $text_elements ): void {
// Matches PAGE_HEIGHT in the editor, as also seen in amp-story-grid-layer[aspect-ratio].
$page_height = 618;
$has_h1 = false;
/**
* The individual text element.
*
* @var DOMElement $text_el The text element.
*/
foreach ( $text_elements as $text_el ) {
$style = $text_el->getAttribute( 'style' );
$matches = [];
// See https://github.com/GoogleForCreators/web-stories-wp/issues/10726.
if ( \strlen( trim( $text_el->textContent ) ) <= 3 ) {
continue;
}
if ( ! preg_match( '/font-size:([^em]+)em/', $style, $matches ) ) {
continue;
}
// Contains the font-size in em.
// This is basically reversing the dataToFontSizeY() logic. Example:
// 0.582524em roughly equals 36 editor pixels: 0.582524 * 618 / 10 = 35.9999px.
$font_size_in_em = (float) $matches[1];
$font_size_in_px = round( $font_size_in_em * $page_height / 10 );
if ( $font_size_in_px >= 36 && ! $has_h1 ) {
$this->change_tag_name( $text_el, 'h1' );
$has_h1 = true;
continue;
}
if ( $font_size_in_px >= 27 ) {
$this->change_tag_name( $text_el, 'h2' );
} elseif ( $font_size_in_px >= 21 ) {
$this->change_tag_name( $text_el, 'h3' );
}
}
}
/**
* Changes an element's tag name.
*
* @since 1.18.0
*
* @param DOMElement $node Element whose tag name should be changed.
* @param string $tag_name Desired new tag name, e.g. h1 or h2.
*/
private function change_tag_name( DOMElement $node, string $tag_name ): void {
/**
* Owner document.
*
* @var Document|AMP_Document Owner document.
*/
$document = $node->ownerDocument;
$new_node = $document->createElement( $tag_name );
if ( ! $new_node instanceof DOMElement ) {
return;
}
// Copy over all children first.
foreach ( $node->childNodes as $child ) {
/**
* Child node.
*
* @var DOMNode $child Child node.
*/
$new_node->appendChild( $document->importNode( $child->cloneNode( true ), true ) );
}
// Then, copy over all attributes.
foreach ( $node->attributes as $attr ) {
/**
* Attribute.
*
* @var DOMAttr $attr Attribute.
*/
$new_node->setAttribute( $attr->nodeName, $attr->nodeValue ?? '' );
}
if ( $node->parentNode ) {
$node->parentNode->replaceChild( $new_node, $node );
}
}
/**
* Sanitizes <amp-story-page-outlink> elements to ensure they're always valid.
*
* Removes empty `cta-image` attributes.
* Ensures the element is always the last child of <amp-story-page>.
*
* @since 1.13.0
*
* @param Document|AMP_Document $document Document instance.
*/
private function sanitize_amp_story_page_outlink( $document ): void {
$outlink_elements = $document->getElementsByTagName( 'amp-story-page-outlink' );
/**
* The <amp-story-page-outlink> element
*
* @var DOMElement $element The <amp-story-page-outlink> element
*/
foreach ( $outlink_elements as $element ) {
if ( ! $element->getAttribute( 'cta-image' ) ) {
$element->removeAttribute( 'cta-image' );
}
$amp_story_page = $element->parentNode;
if ( $amp_story_page && $element !== $amp_story_page->lastChild ) {
$amp_story_page->removeChild( $element );
$amp_story_page->appendChild( $element );
}
}
}
/**
* Replaces the placeholder of publisher logo in the content.
*
* @since 1.1.0
*
* @param Document|AMP_Document $document Document instance.
* @param string $publisher_logo Publisher logo.
*/
private function add_publisher_logo( $document, string $publisher_logo ): void {
/**
* The <amp-story> element.
*
* @var DOMElement|DOMNode $story_element The <amp-story> element.
*/
$story_element = $document->body->getElementsByTagName( 'amp-story' )->item( 0 );
if ( ! $story_element instanceof DOMElement ) {
return;
}
// Add a publisher logo if missing or just a placeholder.
$existing_publisher_logo = $story_element->getAttribute( 'publisher-logo-src' );
// Backward compatibility for when fallback-wordpress-publisher-logo.png was provided by the plugin.
if ( ! $existing_publisher_logo || str_contains( $existing_publisher_logo, 'fallback-wordpress-publisher-logo.png' ) ) {
$story_element->setAttribute( 'publisher-logo-src', $publisher_logo );
}
if ( ! $story_element->getAttribute( 'publisher-logo-src' ) ) {
$story_element->setAttribute( 'publisher-logo-src', $publisher_logo );
}
}
/**
* Replaces the placeholder of publisher in the content.
*
* Ensures the `publisher` attribute exists if missing.
*
* @since 1.7.0
*
* @param Document|AMP_Document $document Document instance.
* @param string $publisher Publisher logo.
*/
private function add_publisher( $document, string $publisher ): void {
/**
* The <amp-story> element.
*
* @var DOMElement|DOMNode $story_element The <amp-story> element.
*/
$story_element = $document->body->getElementsByTagName( 'amp-story' )->item( 0 );
if ( ! $story_element instanceof DOMElement ) {
return;
}
if ( $publisher || ! $story_element->hasAttribute( 'publisher' ) ) {
$story_element->setAttribute( 'publisher', $publisher );
}
}
/**
* Adds square, and landscape poster images to the <amp-story>.
*
* @since 1.1.0
*
* @param Document|AMP_Document $document Document instance.
* @param string[] $poster_images List of poster images, keyed by type.
*/
private function add_poster_images( $document, array $poster_images ): void {
/**
* The <amp-story> element.
*
* @var DOMElement|DOMNode $story_element The <amp-story> element.
*/
$story_element = $document->body->getElementsByTagName( 'amp-story' )->item( 0 );
if ( ! $story_element instanceof DOMElement ) {
return;
}
// The story sanitizer only passes valid, non-empty URLs that are already escaped.
// That means we don't need to do any additional checks here or worry about accidentally overriding
// an existing poster-portrait-src attribute value with an empty one.
foreach ( $poster_images as $attr => $url ) {
$story_element->setAttribute( $attr, $url );
}
if ( ! $story_element->getAttribute( 'poster-portrait-src' ) ) {
$story_element->setAttribute( 'poster-portrait-src', '' );
}
}
/**
* De-duplicate inline styles in the story.
*
* Greatly reduce the amount of `style[amp-custom]` CSS for stories by de-duplicating inline styles
* and moving to simple class selector style rules, avoiding the specificity hack
* that the AMP plugin's style sanitizer employs.
*
* @since 1.8.0
*
* @param Document|AMP_Document $document Document instance.
*/
private function deduplicate_inline_styles( $document ): void {
$elements_by_inline_style = [];
// Gather all elements based on their common inline styles.
$elements_with_style = $document->xpath->query( '//*[ @style ]' );
if ( $elements_with_style instanceof DOMNodeList && $elements_with_style->length > 0 ) {
/**
* The element with inline styles.
*
* @var DOMElement $styled_element The element.
*/
foreach ( $elements_with_style as $styled_element ) {
$value = $styled_element->getAttribute( 'style' );
$styled_element->removeAttribute( 'style' );
$elements_by_inline_style[ $value ][] = $styled_element;
}
}
if ( empty( $elements_by_inline_style ) ) {
return;
}
$style_element = $document->createElement( 'style' );
if ( ! $style_element ) {
return;
}
$document->head->appendChild( $style_element );
// Create style rule for each inline style and add class name to each element.
foreach ( $elements_by_inline_style as $inline_style => $styled_elements ) {
$inline_style_class_name = '_' . substr( md5( (string) $inline_style ), 0, 7 );
$style_rule = $document->createTextNode( sprintf( '.%s{%s}', $inline_style_class_name, $inline_style ) );
$style_element->appendChild( $style_rule );
/**
* The element with inline styles.
*
* @var DOMElement $styled_element The element.
*/
foreach ( $styled_elements as $styled_element ) {
$class_name = $inline_style_class_name;
if ( $styled_element->hasAttribute( 'class' ) ) {
$class_name .= ' ' . $styled_element->getAttribute( 'class' );
}
$styled_element->setAttribute( 'class', $class_name );
}
}
}
/**
* Enables using video cache by adding the necessary attribute to `<amp-video>`
*
* @since 1.10.0
*
* @param Document|AMP_Document $document Document instance.
* @param bool $video_cache_enabled Whether video cache is enabled.
*/
private function add_video_cache( $document, bool $video_cache_enabled ): void {
if ( ! $video_cache_enabled ) {
return;
}
$videos = $document->body->getElementsByTagName( 'amp-video' );
/**
* The <amp-video> element
*
* @var DOMElement $video The <amp-video> element
*/
foreach ( $videos as $video ) {
$video->setAttribute( 'cache', 'google' );
}
}
/**
* Determines whether a URL is a `blob:` URL.
*
* @since 1.9.0
*
* @param string $url URL.
* @return bool Whether it's a blob URL.
*/
private function is_blob_url( string $url ): bool {
return str_starts_with( $url, 'blob:' );
}
/**
* Remove `blob:` URLs from videos and images that might have slipped through.
*
* @since 1.9.0
*
* @param Document|AMP_Document $document Document instance.
*/
private function remove_blob_urls( $document ): void {
/**
* List of <amp-video> elements.
*
* @var DOMElement[] $videos Video elements.
*/
$videos = $document->body->getElementsByTagName( 'amp-video' );
foreach ( $videos as $video ) {
if ( $this->is_blob_url( $video->getAttribute( 'poster' ) ) ) {
$video->setAttribute( 'poster', '' );
}
if ( $this->is_blob_url( $video->getAttribute( 'artwork' ) ) ) {
$video->setAttribute( 'artwork', '' );
}
/**
* List of <source> child elements.
*
* @var DOMElement[] $video_sources Video source elements.
*/
$video_sources = $video->getElementsByTagName( 'source' );
foreach ( $video_sources as $source ) {
if ( $this->is_blob_url( $source->getAttribute( 'src' ) ) ) {
$source->setAttribute( 'src', '' );
}
}
}
/**
* List of <amp-img> elements.
*
* @var DOMElement[] $images Image elements.
*/
$images = $document->body->getElementsByTagName( 'amp-img' );
foreach ( $images as $image ) {
if ( $this->is_blob_url( $image->getAttribute( 'src' ) ) ) {
$image->setAttribute( 'src', '' );
}
}
}
/**
* Sanitize amp-img[srcset] attributes to remove duplicates.
*
* @since 1.10.0
*
* @param Document|AMP_Document $document Document instance.
*/
private function sanitize_srcset( $document ): void {
/**
* List of <amp-img> elements.
*
* @var DOMElement[] $images Image elements.
*/
$images = $document->body->getElementsByTagName( 'amp-img' );
foreach ( $images as $image ) {
$srcset = $image->getAttribute( 'srcset' );
if ( ! $srcset ) {
continue;
}
$matches = [];
// Matches every srcset entry (consisting of a URL and a width descriptor) within `srcset=""`.
// Not using explode(',') to not break with URLs containing commas.
// Given "foo1,2/image.png 123w, foo2,3/image.png 456w", the named capture group "entry"
// will contain "foo1,2/image.png 123w" and "foo2,3/image.png 456w", without the trailing commas.
preg_match_all( '/((?<entry>[^ ]+ [\d]+w),?)/', $srcset, $matches );
$entries = $matches['entry'] ?? [];
$entries_by_widths = [];
foreach ( $entries as $entry ) {
$entry_data = explode( ' ', $entry );
if ( ! isset( $entries_by_widths[ $entry_data[1] ] ) ) {
$entries_by_widths[ $entry_data[1] ] = $entry;
}
}
$image->setAttribute( 'srcset', implode( ', ', $entries_by_widths ) );
}
}
/**
* Remove images referencing the grid-placeholder.png file which has since been removed.
*
* Prevents 404 errors for non-existent image files when creators forget to replace/remove
* the placeholder image.
*
* The placeholder functionality was removed in v1.14.0, nevertheless older stories could still
* reference the files.
*
* @since 1.14.0
*
* @link https://github.com/googleforcreators/web-stories-wp/issues/9530
*
* @param Document|AMP_Document $document Document instance.
*/
private function remove_page_template_placeholder_images( $document ): void {
// Catches "assets/images/editor/grid-placeholder.png" as well as
// "web-stories/assets/images/adde98ae406d6b5c95d111a934487252.png" (v1.14.0)
// and potentially other variants.
$placeholder_img = 'plugins/web-stories/assets/images';
/**
* List of <amp-img> elements.
*
* @var DOMElement[] $images Image elements.
*/
$images = $document->body->getElementsByTagName( 'amp-img' );
foreach ( $images as $image ) {
$src = $image->getAttribute( 'src' );
if ( $image->parentNode && str_contains( $src, $placeholder_img ) ) {
$image->parentNode->removeChild( $image );
}
}
}
/**
* Sanitizes <title> tags and meta descriptions.
*
* Ensures there's always just exactly one of each present.
*
* @since 1.28.0
*
* @link https://github.com/googleforcreators/web-stories-wp/issues/12655
*
* @param Document|AMP_Document $document Document instance.
* @param string $title_tag Title text to use if it's missing.
* @param string $description Description to use if it's missing.
*/
private function sanitize_title_and_meta_description( $document, string $title_tag, string $description ): void {
/**
* List of <title> elements.
*
* @var DOMNodeList<DOMElement> $titles Title elements.
*/
$titles = $document->head->getElementsByTagName( 'title' );
if ( $titles->length > 1 ) {
foreach ( $titles as $index => $title ) {
if ( 0 === $index ) {
continue;
}
$document->head->removeChild( $title );
}
}
if ( 0 === $titles->length && ! empty( $title_tag ) ) {
/**
* New title tag element.
*
* @var DOMElement $new_title
*/
$new_title = $document->createElement( 'title' );
/**
* Title text node.
*
* @var \DOMText $text_node
*/
$text_node = $document->createTextNode( $title_tag );
$new_title->appendChild( $text_node );
$document->head->appendChild( $new_title );
}
/**
* List of meta descriptions.
*
* @var DOMNodeList<DOMElement> $meta_descriptions Meta descriptions.
*/
$meta_descriptions = $document->xpath->query( './/meta[@name="description"]' );
if ( $meta_descriptions->length > 1 ) {
foreach ( $meta_descriptions as $index => $meta_description ) {
if ( 0 === $index ) {
continue;
}
$document->head->removeChild( $meta_description );
}
}
if ( 0 === $meta_descriptions->length && ! empty( $description ) ) {
/**
* New meta description element.
*
* @var DOMElement $new_description
*/
$new_description = $document->createElement( 'meta' );
$new_description->setAttribute( 'name', 'description' );
$new_description->setAttribute( 'content', $description );
$document->head->appendChild( $new_description );
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* Class AMP_Story_Player_Assets.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use WP_Scripts;
use WP_Styles;
/**
* Class AMP_Story_Player_Assets
*/
class AMP_Story_Player_Assets implements Service, Registerable {
/**
* Script handle.
*/
public const SCRIPT_HANDLE = 'standalone-amp-story-player';
/**
* Runs on instantiation.
*
* @since 1.8.0
*/
public function register(): void {
add_action( 'wp_default_styles', [ $this, 'register_style' ] );
add_action( 'wp_default_scripts', [ $this, 'register_script' ] );
}
/**
* Registers the amp player style.
*
* @since 1.8.0
*
* @param WP_Styles $wp_styles WP_Styles instance.
*/
public function register_style( WP_Styles $wp_styles ): void {
$wp_styles->add(
self::SCRIPT_HANDLE,
'https://cdn.ampproject.org/amp-story-player-v0.css',
[],
'v0'
);
}
/**
* Registers the amp player script.
*
* @since 1.8.0
*
* @param WP_Scripts $wp_scripts WP_Scripts instance.
*/
public function register_script( WP_Scripts $wp_scripts ): void {
$wp_scripts->add(
self::SCRIPT_HANDLE,
'https://cdn.ampproject.org/amp-story-player-v0.js',
[],
'v0',
false
);
}
}

View File

@@ -0,0 +1,147 @@
<?php
/**
* Class AdSense
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\HasRequirements;
/**
* Class AdSense
*/
class AdSense extends Service_Base implements HasRequirements {
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Analytics constructor.
*
* @since 1.12.0
*
* @param Settings $settings Settings instance.
* @return void
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Initializes all hooks.
*
* @since 1.3.0
*/
public function register(): void {
add_action( 'web_stories_print_analytics', [ $this, 'print_adsense_tag' ] );
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because settings needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'settings' ];
}
/**
* Prints the <amp-story-auto-ads> tag for single stories.
*
* @since 1.3.0
*/
public function print_adsense_tag(): void {
$publisher = $this->get_publisher_id();
$slot = $this->get_slot_id();
$enabled = $this->is_enabled();
if ( ! $enabled || ! $publisher || ! $slot ) {
return;
}
?>
<amp-story-auto-ads>
<script type="application/json">
{
"ad-attributes": {
"type": "adsense",
"data-ad-client": "<?php echo esc_js( $publisher ); ?>",
"data-ad-slot": "<?php echo esc_js( $slot ); ?>"
}
}
</script>
</amp-story-auto-ads>
<?php
}
/**
* Returns the Google AdSense publisher ID.
*
* @since 1.3.0
*
* @return string Publisher ID.
*/
private function get_publisher_id(): string {
/**
* Publisher ID.
*
* @var string $publisher_id
*/
$publisher_id = $this->settings->get_setting( $this->settings::SETTING_NAME_ADSENSE_PUBLISHER_ID );
return $publisher_id;
}
/**
* Returns the Google AdSense slot ID.
*
* @since 1.3.0
*
* @return string Slot ID.
*/
private function get_slot_id(): string {
/**
* Slot ID.
*
* @var string
*/
return $this->settings->get_setting( $this->settings::SETTING_NAME_ADSENSE_SLOT_ID );
}
/**
* Returns if Google AdSense is enabled.
*
* @since 1.3.0
*/
private function is_enabled(): bool {
return ( 'adsense' === $this->settings->get_setting( $this->settings::SETTING_NAME_AD_NETWORK, 'none' ) );
}
}

View File

@@ -0,0 +1,141 @@
<?php
/**
* Class Ad_Manager
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\HasRequirements;
/**
* Class Ad_Manager
*/
class Ad_Manager extends Service_Base implements HasRequirements {
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Analytics constructor.
*
* @since 1.12.0
*
* @param Settings $settings Settings instance.
* @return void
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Initializes all hooks.
*
* @since 1.3.0
*/
public function register(): void {
add_action( 'web_stories_print_analytics', [ $this, 'print_ad_manager_tag' ] );
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because settings needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'settings' ];
}
/**
* Prints the <amp-story-auto-ads> tag for single stories.
*
* @since 1.3.0
*/
public function print_ad_manager_tag(): void {
$slot = $this->get_slot_id();
$enabled = $this->is_enabled();
if ( ! $enabled || ! $slot ) {
return;
}
$configuration = [
'ad-attributes' => [
'type' => 'doubleclick',
'data-slot' => $slot,
],
];
/**
* Filters Google Ad Manager configuration passed to `<amp-story-auto-ads>`.
*
* @since 1.10.0
*
* @param array $settings Ad Manager configuration.
* @param string $slot Google Ad_Manager slot ID.
*/
$configuration = apply_filters( 'web_stories_ad_manager_configuration', $configuration, $slot );
?>
<amp-story-auto-ads>
<script type="application/json">
<?php echo wp_json_encode( $configuration, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); ?>
</script>
</amp-story-auto-ads>
<?php
}
/**
* Returns the Google Ad_Manager slot ID.
*
* @since 1.3.0
*
* @return string Slot ID.
*/
private function get_slot_id(): string {
/**
* Slot ID.
*
* @var string
*/
return $this->settings->get_setting( $this->settings::SETTING_NAME_AD_MANAGER_SLOT_ID );
}
/**
* Returns if Google manager is enabled.
*
* @since 1.3.0
*/
private function is_enabled(): bool {
return ( 'admanager' === $this->settings->get_setting( $this->settings::SETTING_NAME_AD_NETWORK, 'none' ) );
}
}

View File

@@ -0,0 +1,287 @@
<?php
/**
* Class Activation_Notice.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Assets;
use Google\Web_Stories\Infrastructure\PluginActivationAware;
use Google\Web_Stories\Infrastructure\PluginDeactivationAware;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service as ServiceInterface;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories\Tracking;
/**
* Class Activation_Notice.
*/
class Activation_Notice implements ServiceInterface, Registerable, PluginActivationAware, PluginDeactivationAware, PluginUninstallAware {
/**
* Script handle.
*/
public const SCRIPT_HANDLE = 'web-stories-activation-notice';
/**
* Option name.
*/
public const OPTION_SHOW_ACTIVATION_NOTICE = 'web_stories_show_activation_notice';
/**
* Assets instance.
*
* @var Assets Assets instance.
*/
private Assets $assets;
/**
* Constructor.
*
* @since 1.0.0
*
* @param Assets $assets Assets instance.
*/
public function __construct( Assets $assets ) {
$this->assets = $assets;
}
/**
* Initializes the plugin activation notice.
*
* @since 1.0.0
*/
public function register(): void {
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
add_action( 'admin_notices', [ $this, 'render_notice' ] );
add_action( 'network_admin_notices', [ $this, 'render_notice' ] );
}
/**
* Act on plugin activation.
*
* @since 1.13.0
*
* @param bool $network_wide Whether the activation was done network-wide.
*/
public function on_plugin_activation( bool $network_wide ): void {
$this->set_activation_flag( $network_wide );
}
/**
* Act on plugin deactivation.
*
* @since 1.13.0
*
* @param bool $network_wide Whether the deactivation was done network-wide.
*/
public function on_plugin_deactivation( bool $network_wide ): void {
$this->delete_activation_flag( $network_wide );
}
/**
* Enqueues assets for the plugin activation notice.
*
* @since 1.0.0
*
* @param string $hook_suffix The current admin page.
*/
public function enqueue_assets( string $hook_suffix ): void {
if ( ! $this->is_plugins_page( $hook_suffix ) || ! $this->get_activation_flag( is_network_admin() ) ) {
return;
}
/**
* Prevent the default WordPress "Plugin Activated" notice from rendering.
*
* @link https://github.com/WordPress/WordPress/blob/e1996633228749cdc2d92bc04cc535d45367bfa4/wp-admin/plugins.php#L569-L570
*/
unset( $_GET['activate'] ); // phpcs:ignore WordPress.Security.NonceVerification, WordPress.VIP.SuperGlobalInputUsage
$this->assets->enqueue_style( Google_Fonts::SCRIPT_HANDLE );
$this->assets->enqueue_script_asset( self::SCRIPT_HANDLE, [ Tracking::SCRIPT_HANDLE ] );
wp_localize_script(
self::SCRIPT_HANDLE,
'webStoriesActivationSettings',
$this->get_script_settings()
);
}
/**
* Renders the plugin activation notice.
*
* @since 1.0.0
*/
public function render_notice(): void {
global $hook_suffix;
if ( ! $this->is_plugins_page( $hook_suffix ) ) {
return;
}
$network_wide = is_network_admin();
$flag = $this->get_activation_flag( $network_wide );
if ( ! $flag ) {
return;
}
// Unset the flag so that the notice only shows once.
$this->delete_activation_flag( $network_wide );
require_once WEBSTORIES_PLUGIN_DIR_PATH . 'includes/templates/admin/activation-notice.php';
}
/**
* Deletes the flag that the plugin has just been uninstalled.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
if ( is_multisite() ) {
delete_site_option( self::OPTION_SHOW_ACTIVATION_NOTICE );
}
delete_option( self::OPTION_SHOW_ACTIVATION_NOTICE );
}
/**
* Returns script settings as an array.
*
* @since 1.0.0
*
* @return array{id: string, config: array<string, bool|string>,publicPath: string} Script settings.
*/
protected function get_script_settings(): array {
$new_story_url = admin_url(
add_query_arg(
[
'post_type' => Story_Post_Type::POST_TYPE_SLUG,
],
'post-new.php'
)
);
$dashboard_url = admin_url(
add_query_arg(
[
'post_type' => Story_Post_Type::POST_TYPE_SLUG,
'page' => 'stories-dashboard',
],
'edit.php'
)
);
$demo_story_url = admin_url(
add_query_arg(
[
'post_type' => Story_Post_Type::POST_TYPE_SLUG,
'web-stories-demo' => 1,
],
'post-new.php'
)
);
return [
'id' => 'web-stories-plugin-activation-notice',
'config' => [
'isRTL' => is_rtl(),
'cdnURL' => trailingslashit( WEBSTORIES_CDN_URL ),
'demoStoryURL' => $demo_story_url,
'newStoryURL' => $new_story_url,
'dashboardURL' => $dashboard_url,
],
'publicPath' => $this->assets->get_base_url( 'assets/js/' ),
];
}
/**
* Determines whether we're currently on the Plugins page or not.
*
* @since 1.0.0
*
* @param mixed $hook_suffix Current hook_suffix.
* @return bool Whether we're on the Plugins page.
*/
protected function is_plugins_page( $hook_suffix ): bool {
return ( ! empty( $hook_suffix ) && 'plugins.php' === $hook_suffix );
}
/**
* Sets the flag that the plugin has just been activated.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.13.0
*
* @param bool $network_wide Whether the plugin is being activated network-wide.
*/
protected function set_activation_flag( bool $network_wide = false ): bool {
if ( $network_wide ) {
return update_site_option( self::OPTION_SHOW_ACTIVATION_NOTICE, '1' );
}
return update_option( self::OPTION_SHOW_ACTIVATION_NOTICE, '1', false );
}
/**
* Gets the flag that the plugin has just been activated.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.13.0
*
* @param bool $network_wide Whether to check the flag network-wide.
* @return bool True if just activated, false otherwise.
*/
protected function get_activation_flag( bool $network_wide = false ): bool {
if ( $network_wide ) {
return (bool) get_site_option( self::OPTION_SHOW_ACTIVATION_NOTICE, false );
}
return (bool) get_option( self::OPTION_SHOW_ACTIVATION_NOTICE, false );
}
/**
* Deletes the flag that the plugin has just been activated.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.13.0
*
* @param bool $network_wide Whether the plugin is being deactivated network-wide.
* @return bool True if flag deletion is successful, false otherwise.
*/
protected function delete_activation_flag( bool $network_wide = false ): bool {
if ( $network_wide ) {
return delete_site_option( self::OPTION_SHOW_ACTIVATION_NOTICE );
}
return delete_option( self::OPTION_SHOW_ACTIVATION_NOTICE );
}
}

View File

@@ -0,0 +1,271 @@
<?php
/**
* Admin class.
*
* Responsible for WordPress admin integration.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Context;
use Google\Web_Stories\Model\Story;
use Google\Web_Stories\Renderer\Story\Image;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Story_Post_Type;
use WP_Post;
/**
* Admin class.
*/
class Admin extends Service_Base {
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Single constructor.
*
* @param Settings $settings Settings instance.
* @param Context $context Context instance.
*/
public function __construct( Settings $settings, Context $context ) {
$this->settings = $settings;
$this->context = $context;
}
/**
* Initialize admin-related functionality.
*
* @since 1.0.0
*/
public function register(): void {
add_filter( 'admin_body_class', [ $this, 'admin_body_class' ], 99 );
add_filter( 'default_content', [ $this, 'prefill_post_content' ], 10, 2 );
add_filter( 'default_title', [ $this, 'prefill_post_title' ] );
add_filter( 'display_media_states', [ $this, 'media_states' ], 10, 2 );
}
/**
* Get the action to use for registering the service.
*
* @since 1.6.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'admin_init';
}
/**
* Filter the list of admin classes.
*
* Makes sure the admin menu is collapsed when accessing
* the dashboard and the editor.
*
* @since 1.0.0
*
* @param string|mixed $class_name Current classes.
* @return string|mixed List of Classes.
*/
public function admin_body_class( $class_name ) {
if ( ! $this->context->is_story_editor() ) {
return $class_name;
}
// Default WordPress posts list table screen and dashboard.
if ( 'post' !== $this->context->get_screen_base() ) {
return $class_name;
}
$class_name .= ' edit-story';
// Overrides regular WordPress behavior by collapsing the admin menu by default.
if ( ! str_contains( $class_name, 'folded' ) ) {
$class_name .= ' folded';
}
return $class_name;
}
/**
* Pre-fills post content with a web-story/embed block.
*
* @since 1.0.0
*
* @param string|mixed $content Default post content.
* @param WP_Post|null $post Post object.
* @return string|mixed Pre-filled post content if applicable, or the default content otherwise.
*/
public function prefill_post_content( $content, ?WP_Post $post ) {
if ( ! $post ) {
return $content;
}
if ( ! isset( $_GET['from-web-story'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $content;
}
/**
* Story ID.
*
* @var string $from_web_story
*/
$from_web_story = $_GET['from-web-story']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$post_id = absint( sanitize_text_field( (string) wp_unslash( $from_web_story ) ) );
if ( ! $post_id || Story_Post_Type::POST_TYPE_SLUG !== get_post_type( $post_id ) ) {
return $content;
}
if ( ! current_user_can( 'read_post', $post_id ) ) {
return $content;
}
$story = new Story();
if ( ! $story->load_from_post( $post_id ) ) {
return $content;
}
if ( ! $story->get_title() ) {
$story->set_title( __( 'Web Story', 'web-stories' ) );
}
$args = [
'align' => 'none',
'height' => 600,
'width' => 360,
];
if ( ! use_block_editor_for_post( $post ) ) {
$content = '[web_stories_embed url="%1$s" title="%2$s" poster="%3$s" width="%4$s" height="%5$s" align="%6$s"]';
return sprintf(
$content,
esc_url( $story->get_url() ),
esc_attr( $story->get_title() ),
esc_url( $story->get_poster_portrait() ),
absint( $args['width'] ),
absint( $args['height'] ),
esc_attr( $args['align'] )
);
}
$story->set_poster_sizes( '' );
$story->set_poster_srcset( '' );
$renderer = new Image( $story );
$html = $renderer->render( $args );
$content = '<!-- wp:web-stories/embed {"blockType":"url","url":"%1$s","title":"%2$s","poster":"%3$s","width":"%4$s","height":"%5$s","align":"%6$s","stories": [%7$s]} -->%8$s<!-- /wp:web-stories/embed -->';
// note $story->get_url should not be escaped here (esc_url()) see https://github.com/GoogleForCreators/web-stories-wp/issues/11371.
return sprintf(
$content,
$story->get_url(),
esc_js( $story->get_title() ),
esc_url( $story->get_poster_portrait() ),
absint( $args['width'] ),
absint( $args['height'] ),
esc_js( $args['align'] ),
absint( $post_id ),
$html
);
}
/**
* Pre-fills post title with the story title.
*
* @since 1.0.0
*
* @param string|mixed $title Default post title.
* @return string|mixed Pre-filled post title if applicable, or the default title otherwise.
*/
public function prefill_post_title( $title ) {
if ( ! isset( $_GET['from-web-story'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $title;
}
/**
* Story ID.
*
* @var string $from_web_story
*/
$from_web_story = $_GET['from-web-story']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$post_id = absint( sanitize_text_field( (string) wp_unslash( $from_web_story ) ) );
if ( ! $post_id ) {
return $title;
}
if ( ! current_user_can( 'read_post', $post_id ) ) {
return $title;
}
$post = get_post( $post_id );
if ( ! $post instanceof WP_Post || Story_Post_Type::POST_TYPE_SLUG !== $post->post_type ) {
return $title;
}
// Not using get_the_title() because we need the raw title.
// Otherwise it runs through wptexturize() and the like, which we want to avoid.
return $post->post_title;
}
/**
* Adds active publisher logo to media state output.
*
* @since 1.23.0
*
* @param mixed $media_states Array of media states.
* @param WP_Post $post Post object.
* @return mixed Filtered media states.
*/
public function media_states( $media_states, WP_Post $post ) {
if ( ! \is_array( $media_states ) ) {
return $media_states;
}
$active_publisher_logo_id = absint( $this->settings->get_setting( $this->settings::SETTING_NAME_ACTIVE_PUBLISHER_LOGO ) );
if ( $post->ID === $active_publisher_logo_id ) {
$media_states[] = __( 'Web Stories Publisher Logo', 'web-stories' );
}
return $media_states;
}
}

View File

@@ -0,0 +1,366 @@
<?php
/**
* Class Cross_Origin_Isolation.
*
* Check if editor screen, add cross origin header and add crossorigin attribute to tags.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Context;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\User\Preferences;
/**
* Class Cross_Origin_Isolation
*/
class Cross_Origin_Isolation extends Service_Base implements HasRequirements {
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Preferences instance.
*
* @var Preferences Preferences instance.
*/
private Preferences $preferences;
/**
* Constructor.
*
* @since 1.14.0
*
* @param Preferences $preferences Preferences instance.
* @param Context $context Context instance.
*/
public function __construct( Preferences $preferences, Context $context ) {
$this->preferences = $preferences;
$this->context = $context;
}
/**
* Init
*/
public function register(): void {
if ( ! $this->context->is_story_editor() ) {
return;
}
add_action( 'load-post.php', [ $this, 'admin_header' ] );
add_action( 'load-post-new.php', [ $this, 'admin_header' ] );
add_filter( 'style_loader_tag', [ $this, 'style_loader_tag' ], 10, 3 );
add_filter( 'script_loader_tag', [ $this, 'script_loader_tag' ], 10, 3 );
add_filter( 'get_avatar', [ $this, 'get_avatar' ], 10, 6 );
add_action( 'wp_enqueue_media', [ $this, 'override_media_templates' ] );
}
/**
* Get the action to use for registering the service.
*
* @since 1.6.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'current_screen';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.6.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 11;
}
/**
* Get the list of service IDs required for this service to be registered.
*
* @since 1.12.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'user_preferences' ];
}
/**
* Start output buffer to add headers and `crossorigin` attribute everywhere.
*
* @since 1.6.0
*/
public function admin_header(): void {
if ( $this->needs_isolation() ) {
header( 'Cross-Origin-Opener-Policy: same-origin' );
header( 'Cross-Origin-Embedder-Policy: require-corp' );
}
ob_start( [ $this, 'replace_in_dom' ] );
}
/**
* Filters the HTML link tag of an enqueued style.
*
* @since 1.6.0
*
* @param mixed $tag The link tag for the enqueued style.
* @param string $handle The style's registered handle.
* @param string $href The stylesheet's source URL.
* @return string|mixed
*/
public function style_loader_tag( $tag, string $handle, string $href ) {
return $this->add_attribute( $tag, 'href', $href );
}
/**
* Filters the HTML script tag of an enqueued script.
*
* @since 1.6.0
*
* @param mixed $tag The `<script>` tag for the enqueued script.
* @param string $handle The script's registered handle.
* @param string $src The script's source URL.
* @return string|mixed The filtered script tag.
*/
public function script_loader_tag( $tag, string $handle, string $src ) {
return $this->add_attribute( $tag, 'src', $src );
}
/**
* Filter the avatar tag.
*
* @since 1.6.0
*
* @param string|mixed $avatar HTML for the user's avatar.
* @param mixed $id_or_email The avatar to retrieve. Accepts a user_id, Gravatar MD5 hash,
* user email, WP_User object, WP_Post object, or WP_Comment object.
* @param mixed $size Square avatar width and height in pixels to retrieve.
* @param mixed $default_url URL for the default image or a default type. Accepts '404', 'retro', 'monsterid',
* 'wavatar', 'indenticon', 'mystery', 'mm', 'mysteryman', 'blank', or
* 'gravatar_default'. Default is the value of the 'avatar_default' option, with a
* fallback of 'mystery'.
* @param mixed $alt Alternative text to use in the avatar image tag. Default empty.
* @param array<string,mixed> $args Arguments passed to get_avatar_data(), after processing.
* @return string|mixed Filtered avatar tag.
*/
public function get_avatar( $avatar, $id_or_email, $size, $default_url, $alt, array $args ) {
return $this->add_attribute( $avatar, 'src', $args['url'] );
}
/**
* Unhook wp_print_media_templates and replace with custom media templates.
*
* @since 1.8.0
*/
public function override_media_templates(): void {
remove_action( 'admin_footer', 'wp_print_media_templates' );
add_action( 'admin_footer', [ $this, 'custom_print_media_templates' ] );
}
/**
* Add crossorigin attribute to all tags that could have assets loaded from a different domain.
*
* @since 1.8.0
*/
public function custom_print_media_templates(): void {
ob_start();
wp_print_media_templates();
$html = (string) ob_get_clean();
$tags = [
'audio',
'img',
'video',
];
foreach ( $tags as $tag ) {
$html = (string) str_replace( '<' . $tag, '<' . $tag . ' crossorigin="anonymous"', $html );
}
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Determines whether "full" cross-origin isolation is needed.
*
* By default, `crossorigin="anonymous"` attributes are added to all external
* resources to make sure they can be accessed programmatically (e.g. by html-to-image).
*
* However, actual cross-origin isolation by sending COOP and COEP headers is only
* needed when video optimization is enabled
*
* @since 1.14.0
*
* @link https://github.com/googleforcreators/web-stories-wp/issues/9327
* @link https://web.dev/coop-coep/
*
* @return bool Whether the conditional object is needed.
*/
private function needs_isolation(): bool {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return false;
}
// Cross-origin isolation is not needed if users can't upload files anyway.
if ( ! user_can( $user_id, 'upload_files' ) ) {
return false;
}
/**
* Whether the user has opted in to video optimization.
*
* @var string|bool $preference
*/
$preference = $this->preferences->get_preference( $user_id, $this->preferences::MEDIA_OPTIMIZATION_META_KEY );
return rest_sanitize_boolean( $preference );
}
/**
* Process a html string and add attribute attributes to required tags.
*
* @since 1.6.0
*
* @param string $html HTML document as string.
* @return string Processed HTML document.
*/
protected function replace_in_dom( string $html ): string { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
$site_url = site_url();
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
$tags = [
'audio',
'img',
'link',
'script',
'video',
];
$tags = implode( '|', $tags );
$matches = [];
$processed = [];
if ( preg_match_all( '#<(?P<tag>' . $tags . ')[^<]*?(?:>[\s\S]*?</(?P=tag)>|\s*/>)#', $html, $matches ) ) {
/**
* Single match.
*
* @var string $match
*/
foreach ( $matches[0] as $index => $match ) {
$tag = $matches['tag'][ $index ];
if ( str_contains( $match, ' crossorigin=' ) ) {
continue;
}
$match_value = [];
if ( ! preg_match( '/(src|href)=("([^"]+)"|\'([^\']+)\')/', $match, $match_value ) ) {
continue;
}
$attribute = $match_value[1];
$value = $match_value[4] ?? $match_value[3];
$cache_key = 'video' === $tag || 'audio' === $tag ? $tag : $attribute;
// If already processed tag/attribute and value before, skip.
if ( isset( $processed[ $cache_key ] ) && \in_array( $value, $processed[ $cache_key ], true ) ) {
continue;
}
$processed[ $cache_key ][] = $value;
// The only tags that can have <source> children.
if ( 'video' === $tag || 'audio' === $tag ) {
if ( ! str_starts_with( $value, $site_url ) && ! str_starts_with( $value, '/' ) ) {
$html = str_replace( $match, str_replace( '<' . $tag, '<' . $tag . ' crossorigin="anonymous"', $match ), $html );
}
} else {
/**
* Modified HTML.
*
* @var string $html
*/
$html = $this->add_attribute( $html, $attribute, $value );
}
}
}
return $html;
}
/**
* Do replacement to add crossorigin attribute.
*
* @since 1.6.0
*
* @param string|mixed $html HTML string.
* @param string $attribute Attribute to check for.
* @param string|null|mixed $url URL.
* @return string|mixed Filtered HTML string.
*/
protected function add_attribute( $html, string $attribute, $url ) {
/**
* URL.
*
* @var string $url
*/
if ( ! $url || ! \is_string( $html ) ) {
return $html;
}
$site_url = site_url();
$url = esc_url( $url );
if ( str_starts_with( $url, $site_url ) ) {
return $html;
}
if ( str_starts_with( $url, '/' ) ) {
return $html;
}
return str_replace(
[
$attribute . '="' . $url . '"',
"{$attribute}='{$url}'",
],
[
'crossorigin="anonymous" ' . $attribute . '="' . $url . '"',
"crossorigin='anonymous' {$attribute}='{$url}'",
],
$html
);
}
}

View File

@@ -0,0 +1,747 @@
<?php
/**
* Class Customizer
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Infrastructure\Conditional;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Stories_Script_Data;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories\Story_Query;
use WP_Customize_Manager;
use WP_Customize_Setting;
use WP_Error;
/**
* Class customizer settings.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type ThemeSupport array{
* customizer: array{
* view_type: array{default: string, enabled: string[]},
* title: array{default: bool, enabled: bool},
* excerpt: array{default: bool, enabled: bool},
* author: array{default: bool, enabled: bool},
* date: array{default: bool, enabled: bool},
* show_archive_link: array{default: bool, enabled: bool},
* archive_link: array{default: bool|string, enabled: bool|string, label: string},
* sharp_corners: array{default: bool, enabled: bool},
* order: array{default: string},
* orderby: array{default: string},
* circle_size: array{default: int},
* number_of_stories: array{default: int},
* number_of_columns: array{default: int},
* image_alignment: array{default: string}
* }
* }
*
* @phpstan-type StoryAttributes array{
* view_type?: string,
* number_of_columns?: int,
* show_title?: bool,
* show_author?: bool,
* show_date?: bool,
* show_archive_link?: bool|string,
* show_excerpt?: bool,
* image_alignment?: string,
* class?: string,
* show_stories?: bool|string,
* archive_link_label?: string,
* circle_size?: int,
* sharp_corners?: bool,
* order?: string,
* orderby?: string,
* number_of_stories?: int
* }
*/
class Customizer extends Service_Base implements Conditional {
/**
* Customizer section slug.
*
* @since 1.5.0
*/
public const SECTION_SLUG = 'web_story_options';
/**
* Customizer web stories options key.
*
* @since 1.5.0
*/
public const STORY_OPTION = 'web_stories_customizer_settings';
/**
* WP_Customize_Manager instance.
*
* @since 1.5.0
*
* @var WP_Customize_Manager $wp_customize WP_Customize_Manager instance.
*/
private WP_Customize_Manager $wp_customize;
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Stories_Script_Data instance.
*
* @var Stories_Script_Data Stories_Script_Data instance.
*/
protected Stories_Script_Data $stories_script_data;
/**
* Analytics constructor.
*
* @since 1.12.0
*
* @param Settings $settings Settings instance.
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Stories_Script_Data $stories_script_data Stories_Script_Data instance.
* @return void
*/
public function __construct(
Settings $settings,
Story_Post_Type $story_post_type,
Stories_Script_Data $stories_script_data
) {
$this->settings = $settings;
$this->story_post_type = $story_post_type;
$this->stories_script_data = $stories_script_data;
}
/**
* Initializes the customizer logic.
*
* @since 1.5.0
*/
public function register(): void {
add_action( 'customize_register', [ $this, 'register_customizer_settings' ] );
}
/**
* Check whether the conditional object is currently needed.
*
* @since 1.16.0
*
* @return bool Whether the conditional object is needed.
*/
public static function is_needed(): bool {
return ! \function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme();
}
/**
* Registers web stories customizer settings.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
* @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @since 1.5.0
*
* @param WP_Customize_Manager $wp_customize WP_Customize_Manager instance.
*/
public function register_customizer_settings( WP_Customize_Manager $wp_customize ): void {
$this->wp_customize = $wp_customize;
$theme_support = $this->get_stories_theme_support()['customizer'];
$active_callback = fn() => $this->is_option_enabled( 'show_stories' );
$wp_customize->add_section(
self::SECTION_SLUG,
[
'title' => esc_html__( 'Web Stories', 'web-stories' ),
'theme_supports' => 'web-stories',
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[show_stories]',
[
'default' => false,
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[show_stories]',
[
'type' => 'checkbox',
'section' => self::SECTION_SLUG,
'label' => __( 'Display stories', 'web-stories' ),
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[view_type]',
[
'default' => $theme_support['view_type']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[view_type]',
[
'section' => self::SECTION_SLUG,
'label' => _x( 'View Type', 'noun', 'web-stories' ),
'type' => 'select',
'choices' => $this->get_view_type_choices( $theme_support['view_type']['enabled'] ),
'active_callback' => $active_callback,
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[number_of_stories]',
[
'default' => $theme_support['number_of_stories']['default'],
'type' => 'option',
'validate_callback' => [ $this, 'validate_number_of_stories' ],
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[number_of_stories]',
[
'type' => 'number',
'section' => self::SECTION_SLUG,
'label' => __( 'Number of Stories', 'web-stories' ),
'input_attrs' => [
'min' => 1,
'max' => 20,
],
'active_callback' => $active_callback,
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[number_of_columns]',
[
'default' => $theme_support['number_of_columns']['default'],
'type' => 'option',
'validate_callback' => [ $this, 'validate_number_of_columns' ],
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[number_of_columns]',
[
'type' => 'number',
'section' => self::SECTION_SLUG,
'label' => __( 'Number of Columns', 'web-stories' ),
'input_attrs' => [
'min' => 1,
'max' => 4,
],
'active_callback' => fn() => ( $this->is_option_enabled( 'show_stories' ) && $this->is_view_type( 'grid' ) ),
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[orderby]',
[
'default' => $theme_support['orderby']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[orderby]',
[
'section' => self::SECTION_SLUG,
'label' => __( 'Order By', 'web-stories' ),
'type' => 'select',
'choices' => [
'post_title' => __( 'Title', 'web-stories' ),
'post_date' => __( 'Date', 'web-stories' ),
],
'active_callback' => $active_callback,
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[order]',
[
'default' => $theme_support['order']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[order]',
[
'section' => self::SECTION_SLUG,
'label' => __( 'Order', 'web-stories' ),
'type' => 'select',
'choices' => [
'ASC' => __( 'Ascending', 'web-stories' ),
'DESC' => __( 'Descending', 'web-stories' ),
],
'active_callback' => $active_callback,
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[circle_size]',
[
'default' => $theme_support['circle_size']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[circle_size]',
[
'section' => self::SECTION_SLUG,
'label' => __( 'Circle Size', 'web-stories' ),
'type' => 'number',
'input_attrs' => [
'min' => 80,
'max' => 200,
'step' => 5,
],
'active_callback' => fn() => $this->is_option_enabled( 'show_stories' ) && $this->is_view_type( 'circles' ),
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[image_alignment]',
[
'type' => 'option',
'default' => $theme_support['image_alignment']['default'],
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[image_alignment]',
[
'type' => 'radio',
'section' => self::SECTION_SLUG,
'label' => __( 'Image Alignment', 'web-stories' ),
'choices' => [
'left' => __( 'Left', 'web-stories' ),
'right' => __( 'Right', 'web-stories' ),
],
'active_callback' => fn() => ( $this->is_option_enabled( 'show_stories' ) && $this->is_view_type( 'list' ) ),
]
);
if ( $theme_support['title']['enabled'] ) {
$wp_customize->add_setting(
self::STORY_OPTION . '[show_title]',
[
'default' => $theme_support['title']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[show_title]',
[
'type' => 'checkbox',
'section' => self::SECTION_SLUG,
'label' => __( 'Display Title', 'web-stories' ),
'active_callback' => $active_callback,
]
);
}
if ( $theme_support['excerpt']['enabled'] ) {
$wp_customize->add_setting(
self::STORY_OPTION . '[show_excerpt]',
[
'default' => $theme_support['excerpt']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[show_excerpt]',
[
'type' => 'checkbox',
'section' => self::SECTION_SLUG,
'label' => __( 'Display Excerpt', 'web-stories' ),
'active_callback' => fn() => $this->is_option_enabled( 'show_stories' ) && $this->is_view_type( 'list' ),
]
);
}
if ( $theme_support['author']['enabled'] ) {
$wp_customize->add_setting(
self::STORY_OPTION . '[show_author]',
[
'default' => $theme_support['author']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[show_author]',
[
'type' => 'checkbox',
'section' => self::SECTION_SLUG,
'label' => __( 'Display Author', 'web-stories' ),
'active_callback' => fn() => ( $this->is_option_enabled( 'show_stories' ) && ! $this->is_view_type( 'circles' ) ),
]
);
}
if ( $theme_support['date']['enabled'] ) {
$wp_customize->add_setting(
self::STORY_OPTION . '[show_date]',
[
'default' => $theme_support['date']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[show_date]',
[
'type' => 'checkbox',
'section' => self::SECTION_SLUG,
'label' => __( 'Display Date', 'web-stories' ),
'active_callback' => fn() => ( $this->is_option_enabled( 'show_stories' ) && ! $this->is_view_type( 'circles' ) ),
]
);
}
if ( $theme_support['sharp_corners']['enabled'] ) {
$wp_customize->add_setting(
self::STORY_OPTION . '[sharp_corners]',
[
'default' => $theme_support['sharp_corners']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[sharp_corners]',
[
'type' => 'checkbox',
'section' => self::SECTION_SLUG,
'label' => __( 'Sharp Corners', 'web-stories' ),
'active_callback' => fn() => ( $this->is_option_enabled( 'show_stories' ) && ! $this->is_view_type( 'circles' ) ),
]
);
}
if ( $theme_support['archive_link']['enabled'] ) {
$wp_customize->add_setting(
self::STORY_OPTION . '[show_archive_link]',
[
'default' => $theme_support['archive_link']['default'],
'type' => 'option',
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[show_archive_link]',
[
'type' => 'checkbox',
'section' => self::SECTION_SLUG,
'label' => __( 'Display Archive Link', 'web-stories' ),
'active_callback' => $active_callback,
]
);
$wp_customize->add_setting(
self::STORY_OPTION . '[archive_link_label]',
[
'type' => 'option',
'default' => $theme_support['archive_link']['label'],
]
);
$wp_customize->add_control(
self::STORY_OPTION . '[archive_link_label]',
[
'type' => 'text',
'section' => self::SECTION_SLUG,
'label' => __( 'Archive Link Label', 'web-stories' ),
'active_callback' => fn() => ( $this->is_option_enabled( 'show_stories' ) && $this->is_option_enabled( 'show_archive_link' ) ),
]
);
}
}
/**
* Validates the number of story setting value.
*
* @since 1.5.0
*
* @param WP_Error $validity WP_Error object.
* @param int $value Value to be validated.
*/
public function validate_number_of_stories( WP_Error $validity, int $value ): WP_Error {
$value = (int) $value;
if ( $value <= 0 || $value > 20 ) {
$validity->add( 'invalid_number', __( 'The number of stories must be between 1 and 20.', 'web-stories' ) );
}
return $validity;
}
/**
* Validates the number of columns setting value.
*
* @since 1.5.0
*
* @param WP_Error $validity WP_Error object.
* @param int $value Value to be validated.
*/
public function validate_number_of_columns( WP_Error $validity, int $value ): WP_Error {
$value = (int) $value;
if ( $value <= 0 || $value > 5 ) {
$validity->add( 'invalid_number', __( 'The number of columns must be between 1 and 4.', 'web-stories' ) );
}
return $validity;
}
/**
* Renders web stories based on the customizer selected options.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*
* @since 1.5.0
*/
public function render_stories(): string {
/**
* Render options.
*
* @var array<string,string|bool> $options
* @phpstan-var StoryAttributes
*/
$options = (array) $this->settings->get_setting( self::STORY_OPTION );
if ( empty( $options['show_stories'] ) || true !== $options['show_stories'] ) {
return '';
}
$theme_support = $this->get_stories_theme_support()['customizer'];
$story_attributes = [
'view_type' => $options['view_type'] ?? $theme_support['view_type']['default'],
'show_title' => isset( $options['show_title'] ) ? (bool) $options['show_title'] : $theme_support['title']['default'],
'show_excerpt' => isset( $options['show_excerpt'] ) ? (bool) $options['show_excerpt'] : $theme_support['excerpt']['default'],
'show_author' => isset( $options['show_author'] ) ? (bool) $options['show_author'] : $theme_support['author']['default'],
'show_date' => isset( $options['show_date'] ) ? (bool) $options['show_date'] : $theme_support['date']['default'],
'show_archive_link' => isset( $options['show_archive_link'] ) ? (bool) $options['show_archive_link'] : $theme_support['archive_link']['default'],
'archive_link_label' => isset( $options['archive_link_label'] ) ? (string) $options['archive_link_label'] : $theme_support['archive_link']['label'],
'circle_size' => isset( $options['circle_size'] ) ? (int) $options['circle_size'] : $theme_support['circle_size']['default'],
'sharp_corners' => isset( $options['sharp_corners'] ) ? (bool) $options['sharp_corners'] : $theme_support['sharp_corners']['default'],
'image_alignment' => isset( $options['image_alignment'] ) ? (string) $options['image_alignment'] : $theme_support['image_alignment']['default'],
'number_of_columns' => isset( $options['number_of_columns'] ) ? (int) $options['number_of_columns'] : $theme_support['number_of_columns']['default'],
'class' => 'web-stories-list--customizer',
];
$query_arguments = [
'posts_per_page' => isset( $options['number_of_stories'] ) ? (int) $options['number_of_stories'] : $theme_support['number_of_stories']['default'], // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
'orderby' => isset( $options['orderby'] ) ? (string) $options['orderby'] : $theme_support['orderby']['default'],
'order' => isset( $options['order'] ) ? (string) $options['order'] : $theme_support['order']['default'],
];
return ( new Story_Query( $story_attributes, $query_arguments ) )->render();
}
/**
* Get theme support configuration.
*
* @since 1.14.0
*
* @return array Theme support configuration
*
* @phpstan-return ThemeSupport
*/
public function get_stories_theme_support(): array {
/**
* Theme support configuration.
*
* @var ThemeSupport[]|array<int, false> $support
*/
$support = get_theme_support( 'web-stories' );
$support = isset( $support[0] ) && \is_array( $support[0] ) ? $support[0] : [];
$has_archive = (bool) $this->story_post_type->get_has_archive();
$default_support = [
'customizer' => [
'view_type' => [
'enabled' => [ 'circles' ],
'default' => 'circles',
],
'title' => [
'enabled' => true,
'default' => true,
],
'excerpt' => [
'enabled' => true,
'default' => false,
],
'author' => [
'enabled' => true,
'default' => true,
],
'date' => [
'enabled' => false,
'default' => false,
],
'archive_link' => [
'enabled' => $has_archive,
'default' => $has_archive,
'label' => __( 'View all stories', 'web-stories' ),
],
'sharp_corners' => [
'enabled' => false,
'default' => false,
],
'order' => [
'default' => 'DESC',
],
'orderby' => [
'default' => 'post_date',
],
'circle_size' => [
'default' => 150,
],
'number_of_stories' => [
'default' => 10,
],
'number_of_columns' => [
'default' => 2,
],
'image_alignment' => [
'default' => is_rtl() ? 'right' : 'left',
],
],
];
/**
* Theme support config.
*
* @var ThemeSupport $support
*/
$support = $this->parse_args( $support, $default_support );
return $support;
}
/**
* Gets the view type choices.
*
* @since 1.5.0
*
* @param array<string,mixed> $view_type View type to check.
* @return array<string,mixed> An array of view type choices.
*/
private function get_view_type_choices( array $view_type ): array {
$view_type_choices = $this->stories_script_data->get_layouts();
if ( empty( $view_type ) ) {
return $view_type_choices;
}
return array_intersect_key( $view_type_choices, array_fill_keys( $view_type, true ) );
}
/**
* Checks whether the given option is enabled or not.
*
* @since 1.5.0
*
* @param string $option_name The name of the option to check.
* @return bool Returns true if the given option is enabled otherwise false.
*/
private function is_option_enabled( string $option_name ): bool {
$setting = $this->wp_customize->get_setting( self::STORY_OPTION . "[{$option_name}]" );
return ( $setting instanceof WP_Customize_Setting && true === $setting->value() );
}
/**
* Verifies the current view type.
*
* @since 1.5.0
*
* @param string $view_type View type to check.
* @return bool Whether or not current view type matches the one passed.
*/
private function is_view_type( string $view_type ): bool {
$setting = $this->wp_customize->get_setting( self::STORY_OPTION . '[view_type]' );
return ( $setting instanceof WP_Customize_Setting && $view_type === $setting->value() );
}
/**
* Merges user defined arguments into defaults array.
*
* Like wp_parse_args(), but recursive.
*
* @since 1.14.0
*
* @see wp_parse_args()
*
* @param array<string, mixed> $args Value to merge with $defaults.
* @param array<string, array<string, array<string, array<int,string>|bool|int|string>>> $defaults Optional. Array that serves as the defaults. Default empty array.
* @return array<string, mixed> Merged user defined values with defaults.
*/
private function parse_args( array $args, array $defaults = [] ): array {
$parsed_args = $defaults;
foreach ( $args as $key => $value ) {
if ( \is_array( $value ) && isset( $parsed_args[ $key ] ) ) {
/**
* Default value.
*
* @var array<string, array<string, array<string, array<int,string>|bool|int|string>>> $def
*/
$def = $parsed_args[ $key ];
$parsed_args[ $key ] = $this->parse_args( $value, $def );
} else {
$parsed_args[ $key ] = $value;
}
}
return $parsed_args;
}
}

View File

@@ -0,0 +1,576 @@
<?php
/**
* Dashboard class.
*
* Responsible for adding the stories dashboard to WordPress admin.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Assets;
use Google\Web_Stories\Context;
use Google\Web_Stories\Decoder;
use Google\Web_Stories\Experiments;
use Google\Web_Stories\Font_Post_Type;
use Google\Web_Stories\Integrations\Site_Kit;
use Google\Web_Stories\Integrations\WooCommerce;
use Google\Web_Stories\Locale;
use Google\Web_Stories\Media\Types;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Shopping\Shopping_Vendors;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories\Tracking;
/**
* Dashboard class.
*/
class Dashboard extends Service_Base {
/**
* Script handle.
*/
public const SCRIPT_HANDLE = 'web-stories-dashboard';
/**
* Admin page hook suffixes.
*
* @var array<string,string|bool> List of the admin pages' hook_suffix values.
*/
private array $hook_suffix = [];
/**
* Experiments instance.
*
* @var Experiments Experiments instance.
*/
private Experiments $experiments;
/**
* Site_Kit instance.
*
* @var Site_Kit Site_Kit instance.
*/
private Site_Kit $site_kit;
/**
* Decoder instance.
*
* @var Decoder Decoder instance.
*/
private Decoder $decoder;
/**
* Locale instance.
*
* @var Locale Locale instance.
*/
private Locale $locale;
/**
* Google_Fonts instance.
*
* @var Google_Fonts Google_Fonts instance.
*/
private Google_Fonts $google_fonts;
/**
* Assets instance.
*
* @var Assets Assets instance.
*/
private Assets $assets;
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Font_Post_Type instance.
*
* @var Font_Post_Type Font_Post_Type instance.
*/
private Font_Post_Type $font_post_type;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Types instance.
*
* @var Types Types instance.
*/
private Types $types;
/**
* Shopping_Vendors instance.
*
* @var Shopping_Vendors Shopping_Vendors instance.
*/
private Shopping_Vendors $shopping_vendors;
/**
* WooCommerce instance.
*
* @var WooCommerce WooCommerce instance.
*/
private WooCommerce $woocommerce;
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Dashboard constructor.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*
* @since 1.0.0
*
* @param Experiments $experiments Experiments instance.
* @param Site_Kit $site_kit Site_Kit instance.
* @param Decoder $decoder Decoder instance.
* @param Locale $locale Locale instance.
* @param Google_Fonts $google_fonts Google_Fonts instance.
* @param Assets $assets Assets instance.
* @param Font_Post_Type $font_post_type Font_Post_Type instance.
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Context $context Context instance.
* @param Types $types Types instance.
* @param Shopping_Vendors $shopping_vendors Shopping_Vendors instance.
* @param WooCommerce $woocommerce WooCommerce instance.
* @param Settings $settings Settings instance.
*/
public function __construct(
Experiments $experiments,
Site_Kit $site_kit,
Decoder $decoder,
Locale $locale,
Google_Fonts $google_fonts,
Assets $assets,
Font_Post_Type $font_post_type,
Story_Post_Type $story_post_type,
Context $context,
Types $types,
Shopping_Vendors $shopping_vendors,
WooCommerce $woocommerce,
Settings $settings
) {
$this->experiments = $experiments;
$this->decoder = $decoder;
$this->site_kit = $site_kit;
$this->locale = $locale;
$this->google_fonts = $google_fonts;
$this->assets = $assets;
$this->font_post_type = $font_post_type;
$this->story_post_type = $story_post_type;
$this->context = $context;
$this->types = $types;
$this->shopping_vendors = $shopping_vendors;
$this->woocommerce = $woocommerce;
$this->settings = $settings;
}
/**
* Initializes the dashboard logic.
*
* @since 1.0.0
*/
public function register(): void {
add_action( 'admin_menu', [ $this, 'add_menu_page' ] );
add_action( 'admin_init', [ $this, 'redirect_menu_page' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
add_action( 'admin_notices', [ $this, 'display_link_to_dashboard' ] );
add_action( 'load-web-story_page_stories-dashboard', [ $this, 'load_stories_dashboard' ] );
}
/**
* Returns the admin page's hook suffix.
*
* @since 1.0.0
*
* @param string $key The current admin page key.
* @return bool|string The dashboard page's hook_suffix, or false if the user does not have the capability required.
*/
public function get_hook_suffix( string $key ) {
return $this->hook_suffix[ $key ] ?? false;
}
/**
* Registers the dashboard admin menu page.
*
* @since 1.0.0
*/
public function add_menu_page(): void {
$parent = 'edit.php?post_type=' . $this->story_post_type->get_slug();
$settings = $this->get_dashboard_settings();
/**
* The edit_posts capability.
*
* @var string $edit_posts
*/
$edit_posts = $this->story_post_type->get_cap_name( 'edit_posts' );
$this->hook_suffix['stories-dashboard'] = add_submenu_page(
$parent,
__( 'Dashboard', 'web-stories' ),
__( 'Dashboard', 'web-stories' ),
$edit_posts, // phpcs:ignore WordPress.WP.Capabilities.Undetermined
'stories-dashboard',
[ $this, 'render' ],
0
);
if ( isset( $settings['canViewDefaultTemplates'] ) && $settings['canViewDefaultTemplates'] ) {
$this->hook_suffix['stories-dashboard-explore'] = add_submenu_page(
$parent,
__( 'Explore Templates', 'web-stories' ),
__( 'Explore Templates', 'web-stories' ),
$edit_posts, // phpcs:ignore WordPress.WP.Capabilities.Undetermined
'stories-dashboard#/templates-gallery',
'__return_null',
1
);
}
$this->hook_suffix['stories-dashboard-settings'] = add_submenu_page(
$parent,
__( 'Settings', 'web-stories' ),
__( 'Settings', 'web-stories' ),
$edit_posts, // phpcs:ignore WordPress.WP.Capabilities.Undetermined
'stories-dashboard#/editor-settings',
'__return_null',
20
);
}
/**
* Redirects to the correct Dashboard page when clicking on the top-level "Stories" menu item.
*
* @codeCoverageIgnore
*
* @since 1.0.0
*/
public function redirect_menu_page(): void {
global $pagenow;
if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
/**
* Page slug.
*
* @var string $page
*/
$page = $_GET['page']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$page = sanitize_text_field( (string) wp_unslash( $page ) );
if ( 'admin.php' === $pagenow && 'stories-dashboard' === $page ) {
wp_safe_redirect(
add_query_arg(
[
'post_type' => $this->story_post_type->get_slug(),
'page' => 'stories-dashboard',
],
admin_url( 'edit.php' )
)
);
exit;
}
}
/**
* Preload API requests in the dashboard.
*
* Important: keep in sync with usage & definition in React app.
*
* @since 1.0.0
*/
public function load_stories_dashboard(): void {
$rest_url = trailingslashit( $this->story_post_type->get_rest_url() );
$preload_paths = [
'/web-stories/v1/settings/',
'/web-stories/v1/publisher-logos/',
'/web-stories/v1/users/me/',
'/web-stories/v1/taxonomies/?' . build_query(
[
'type' => $this->story_post_type->get_slug(),
'context' => 'edit',
'hierarchical' => 'true',
'show_ui' => 'true',
]
),
$rest_url . '?' . build_query(
[
'_embed' => rawurlencode(
implode(
',',
[ 'wp:lock', 'author' ]
)
),
'context' => 'edit',
'order' => 'desc',
'orderby' => 'modified',
'page' => 1,
'per_page' => 24,
'status' => rawurlencode(
implode(
',',
[ 'draft', 'future', 'pending', 'publish', 'private' ]
)
),
'_web_stories_envelope' => 'true',
'_fields' => rawurlencode(
implode(
',',
[
'id',
'title',
'status',
'date',
'date_gmt',
'modified',
'modified_gmt',
'story_poster',
'link',
'preview_link',
'edit_link',
'_links', // Needed for WP 6.1+.
'_embedded',
// _web_stories_envelope will add these fields, we need them too.
'body',
'status',
'headers',
]
)
),
]
),
];
/**
* Preload common data by specifying an array of REST API paths that will be preloaded.
*
* Filters the array of paths that will be preloaded.
*
* @since 1.0.0
*
* @param string[] $preload_paths Array of paths to preload.
*/
$preload_paths = apply_filters( 'web_stories_dashboard_preload_paths', $preload_paths );
$preload_data = array_reduce(
$preload_paths,
'\Google\Web_Stories\rest_preload_api_request',
[]
);
wp_add_inline_script(
'wp-api-fetch',
sprintf( 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );', wp_json_encode( $preload_data ) ),
'after'
);
}
/**
* Renders the dashboard page.
*
* @since 1.0.0
*/
public function render(): void {
require_once WEBSTORIES_PLUGIN_DIR_PATH . 'includes/templates/admin/dashboard.php';
}
/**
* Enqueues dashboard scripts and styles.
*
* @since 1.0.0
*
* @param string $hook_suffix The current admin page.
*/
public function enqueue_assets( string $hook_suffix ): void {
if ( $this->get_hook_suffix( 'stories-dashboard' ) !== $hook_suffix ) {
return;
}
$this->assets->enqueue_script_asset( self::SCRIPT_HANDLE, [ Tracking::SCRIPT_HANDLE ], false );
$this->assets->enqueue_style_asset( self::SCRIPT_HANDLE, [ $this->google_fonts::SCRIPT_HANDLE ] );
wp_localize_script(
self::SCRIPT_HANDLE,
'webStories',
[
'publicPath' => $this->assets->get_base_url( 'assets/js/' ), // Required before the editor script is enqueued.
'localeData' => $this->assets->get_translations( self::SCRIPT_HANDLE ), // Required for i18n setLocaleData.
]
);
// Dequeue forms.css, see https://github.com/googleforcreators/web-stories-wp/issues/349 .
$this->assets->remove_admin_style( [ 'forms' ] );
}
/**
* Get dashboard settings as an array.
*
* @since 1.0.0
*
* @return array<string,bool|string|int|array<string,mixed>>
*/
public function get_dashboard_settings(): array {
$new_story_url = admin_url(
add_query_arg(
[
'post_type' => $this->story_post_type->get_slug(),
],
'post-new.php'
)
);
// Media settings.
$max_upload_size = wp_max_upload_size();
if ( ! $max_upload_size ) {
$max_upload_size = 0;
}
$mime_types = $this->types->get_allowed_mime_types();
$allowed_image_mime_types = $mime_types['image'];
$vendors = wp_list_pluck( $this->shopping_vendors->get_vendors(), 'label' );
$auto_advance = $this->settings->get_setting( $this->settings::SETTING_NAME_AUTO_ADVANCE );
$page_duration = $this->settings->get_setting( $this->settings::SETTING_NAME_DEFAULT_PAGE_DURATION );
$plugin_file = plugin_basename( WEBSTORIES_PLUGIN_FILE );
$auto_updates = (array) get_site_option( 'auto_update_plugins', [] );
$auto_updates_enabled = \in_array( $plugin_file, $auto_updates, true );
$plugin_updates = get_site_transient( 'update_plugins' );
$needs_update = \is_object( $plugin_updates ) &&
property_exists( $plugin_updates, 'response' ) &&
\is_array( $plugin_updates->response ) &&
! empty( $plugin_updates->response[ $plugin_file ] );
$can_update = current_user_can( 'update_plugins' );
$settings = [
'isRTL' => is_rtl(),
'userId' => get_current_user_id(),
'locale' => $this->locale->get_locale_settings(),
'newStoryURL' => $new_story_url,
'archiveURL' => $this->story_post_type->get_archive_link(),
'defaultArchiveURL' => $this->story_post_type->get_archive_link( true ),
'cdnURL' => trailingslashit( WEBSTORIES_CDN_URL ),
'allowedImageMimeTypes' => $allowed_image_mime_types,
'version' => WEBSTORIES_VERSION,
'encodeMarkup' => $this->decoder->supports_decoding(),
'api' => [
'stories' => trailingslashit( $this->story_post_type->get_rest_url() ),
'media' => '/web-stories/v1/media/',
'currentUser' => '/web-stories/v1/users/me/',
'fonts' => trailingslashit( $this->font_post_type->get_rest_url() ),
'users' => '/web-stories/v1/users/',
'settings' => '/web-stories/v1/settings/',
'pages' => '/wp/v2/pages/',
'publisherLogos' => '/web-stories/v1/publisher-logos/',
'taxonomies' => '/web-stories/v1/taxonomies/',
'products' => '/web-stories/v1/products/',
],
'vendors' => $vendors,
'maxUpload' => $max_upload_size,
'maxUploadFormatted' => size_format( $max_upload_size ),
'editPostsCapabilityName' => $this->story_post_type->get_cap_name( 'edit_posts' ),
'capabilities' => [
'canManageSettings' => current_user_can( 'manage_options' ),
'canUploadFiles' => current_user_can( 'upload_files' ),
],
'canViewDefaultTemplates' => true,
'plugins' => [
'siteKit' => $this->site_kit->get_plugin_status(),
'woocommerce' => $this->woocommerce->get_plugin_status(),
'web-stories' => [
'needsUpdate' => $needs_update && ! $auto_updates_enabled,
'updateLink' => $can_update ? admin_url( 'plugins.php' ) : null,
],
],
'flags' => array_merge(
$this->experiments->get_experiment_statuses( 'general' ),
$this->experiments->get_experiment_statuses( 'dashboard' ),
),
'globalAutoAdvance' => (bool) $auto_advance,
'globalPageDuration' => (float) $page_duration,
];
/**
* Filters settings passed to the web stories dashboard.
*
* @since 1.0.0
*
* @param array $settings Array of settings passed to web stories dashboard.
*/
return apply_filters( 'web_stories_dashboard_settings', $settings );
}
/**
* Displays a link to the Web Stories dashboard on the WordPress list table view.
*
* @since 1.0.0
*/
public function display_link_to_dashboard(): void {
if ( ! $this->context->is_story_editor() ) {
return;
}
if ( 'edit' !== $this->context->get_screen_base() ) {
return;
}
$dashboard_url = add_query_arg(
[
'post_type' => $this->story_post_type->get_slug(),
'page' => 'stories-dashboard',
],
admin_url( 'edit.php' )
)
?>
<div style="margin-top: 20px;">
<a href="<?php echo esc_url( $dashboard_url ); ?>">
<?php esc_html_e( '&larr; Return to Web Stories Dashboard', 'web-stories' ); ?>
</a>
</div>
<?php
}
}

View File

@@ -0,0 +1,523 @@
<?php
/**
* Class Editor
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Assets;
use Google\Web_Stories\Context;
use Google\Web_Stories\Decoder;
use Google\Web_Stories\Experiments;
use Google\Web_Stories\Font_Post_Type;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Locale;
use Google\Web_Stories\Media\Types;
use Google\Web_Stories\Model\Story;
use Google\Web_Stories\Page_Template_Post_Type;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories\Tracking;
use WP_Post;
/**
* Class Editor
*/
class Editor extends Service_Base implements HasRequirements {
/**
* Web Stories editor script handle.
*/
public const SCRIPT_HANDLE = 'web-stories-editor';
/**
* AMP validator script handle.
*/
public const AMP_VALIDATOR_SCRIPT_HANDLE = 'amp-validator';
/**
* The libheif script handle.
*/
public const LIBHEIF_SCRIPT_HANDLE = 'web-stories-libheif';
/**
* Experiments instance.
*
* @var Experiments Experiments instance.
*/
private Experiments $experiments;
/**
* Decoder instance.
*
* @var Decoder Decoder instance.
*/
private Decoder $decoder;
/**
* Meta boxes instance.
*
* @var Meta_Boxes Meta_Boxes instance.
*/
private Meta_Boxes $meta_boxes;
/**
* Locale instance.
*
* @var Locale Locale instance.
*/
private Locale $locale;
/**
* Google_Fonts instance.
*
* @var Google_Fonts Google_Fonts instance.
*/
private Google_Fonts $google_fonts;
/**
* Assets instance.
*
* @var Assets Assets instance.
*/
private Assets $assets;
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Page_Template_Post_Type instance.
*
* @var Page_Template_Post_Type Page_Template_Post_Type instance.
*/
private Page_Template_Post_Type $page_template_post_type;
/**
* Font_Post_Type instance.
*
* @var Font_Post_Type Font_Post_Type instance.
*/
private Font_Post_Type $font_post_type;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Types instance.
*
* @var Types Types instance.
*/
private Types $types;
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Dashboard constructor.
*
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*
* @since 1.0.0
*
* @param Experiments $experiments Experiments instance.
* @param Meta_Boxes $meta_boxes Meta_Boxes instance.
* @param Decoder $decoder Decoder instance.
* @param Locale $locale Locale instance.
* @param Google_Fonts $google_fonts Google_Fonts instance.
* @param Assets $assets Assets instance.
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Page_Template_Post_Type $page_template_post_type Page_Template_Post_Type instance.
* @param Font_Post_Type $font_post_type Font_Post_Type instance.
* @param Context $context Context instance.
* @param Types $types Types instance.
* @param Settings $settings Settings instance.
*/
public function __construct(
Experiments $experiments,
Meta_Boxes $meta_boxes,
Decoder $decoder,
Locale $locale,
Google_Fonts $google_fonts,
Assets $assets,
Story_Post_Type $story_post_type,
Page_Template_Post_Type $page_template_post_type,
Font_Post_Type $font_post_type,
Context $context,
Types $types,
Settings $settings
) {
$this->experiments = $experiments;
$this->meta_boxes = $meta_boxes;
$this->decoder = $decoder;
$this->locale = $locale;
$this->google_fonts = $google_fonts;
$this->assets = $assets;
$this->story_post_type = $story_post_type;
$this->page_template_post_type = $page_template_post_type;
$this->font_post_type = $font_post_type;
$this->context = $context;
$this->types = $types;
$this->settings = $settings;
}
/**
* Initializes the Editor logic.
*
* @since 1.7.0
*/
public function register(): void {
add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] );
add_filter( 'replace_editor', [ $this, 'replace_editor' ], 10, 2 );
add_filter( 'use_block_editor_for_post_type', [ $this, 'filter_use_block_editor_for_post_type' ], 10, 2 );
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story and page template post types need to be registered first.
*
* @since 1.14.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'page_template_post_type', 'story_post_type' ];
}
/**
* Replace default post editor with our own implementation.
*
* @codeCoverageIgnore
*
* @since 1.0.0
*
* @param bool|mixed $replace Bool if to replace editor or not.
* @param WP_Post $post Current post object.
* @return bool|mixed Whether the editor has been replaced.
*/
public function replace_editor( $replace, WP_Post $post ) {
if ( $this->story_post_type->get_slug() === get_post_type( $post ) ) {
$script_dependencies = [
Tracking::SCRIPT_HANDLE,
'postbox',
self::AMP_VALIDATOR_SCRIPT_HANDLE,
self::LIBHEIF_SCRIPT_HANDLE,
];
// Registering here because the script handle is required for wp_add_inline_script in edit-story.php.
$this->assets->register_script_asset( self::SCRIPT_HANDLE, $script_dependencies, false );
// Since the 'replace_editor' filter can be run multiple times, only load the
// custom editor after the 'current_screen' action and when we can be certain the
// $post_type, $post_type_object, $post globals are all set by WordPress.
if ( isset( $GLOBALS['post'] ) && $post === $GLOBALS['post'] && did_action( 'current_screen' ) ) {
require_once WEBSTORIES_PLUGIN_DIR_PATH . 'includes/templates/admin/edit-story.php';
}
return true;
}
return $replace;
}
/**
* Filters whether post type supports the block editor.
*
* Disables the block editor and associated logic (like enqueueing assets)
* for the story post type.
*
* @since 1.0.0
*
* @param bool|mixed $use_block_editor Whether the post type can be edited or not. Default true.
* @param string $post_type The post type being checked.
* @return false|mixed Whether to use the block editor.
*/
public function filter_use_block_editor_for_post_type( $use_block_editor, string $post_type ) {
if ( $this->story_post_type->get_slug() === $post_type ) {
return false;
}
return $use_block_editor;
}
/**
* Enqueue scripts for the element editor.
*
* @since 1.0.0
*
* @param string $hook The current admin page.
*/
public function admin_enqueue_scripts( string $hook ): void {
if ( ! $this->context->is_story_editor() ) {
return;
}
// Only output scripts and styles where in edit screens.
if ( ! \in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) {
return;
}
// Force media model to load.
wp_enqueue_media();
wp_enqueue_script(
self::AMP_VALIDATOR_SCRIPT_HANDLE,
'https://cdn.ampproject.org/v0/validator_wasm.js',
[],
WEBSTORIES_VERSION,
true
);
wp_enqueue_script(
self::LIBHEIF_SCRIPT_HANDLE,
trailingslashit( WEBSTORIES_CDN_URL ) . 'js/libheif-js@1.14.0/libheif.js',
[],
WEBSTORIES_VERSION,
true
);
wp_enqueue_script( self::SCRIPT_HANDLE );
$this->assets->enqueue_style_asset( self::SCRIPT_HANDLE, [ $this->google_fonts::SCRIPT_HANDLE ] );
wp_localize_script(
self::SCRIPT_HANDLE,
'webStories',
[
'publicPath' => $this->assets->get_base_url( 'assets/js/' ), // Required before the editor script is enqueued.
'localeData' => $this->assets->get_translations( self::SCRIPT_HANDLE ), // Required for i18n setLocaleData.
]
);
// Dequeue forms.css, see https://github.com/googleforcreators/web-stories-wp/issues/349 .
$this->assets->remove_admin_style( [ 'forms' ] );
}
/**
* Get editor settings as an array.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.0.0
*
* @return array<string,mixed> Editor settings.
*/
public function get_editor_settings(): array {
$post = get_post();
$story_id = $post->ID ?? null;
$general_settings_url = admin_url( 'options-general.php' );
if ( $story_id ) {
$this->setup_lock( $story_id );
}
// Media settings.
$max_upload_size = wp_max_upload_size();
if ( ! $max_upload_size ) {
$max_upload_size = 0;
}
$dashboard_url = add_query_arg(
[
'post_type' => $this->story_post_type->get_slug(),
'page' => 'stories-dashboard',
],
admin_url( 'edit.php' )
);
$revision_url = admin_url( 'revision.php' );
$dashboard_settings_url = add_query_arg(
[
'post_type' => $this->story_post_type->get_slug(),
'page' => 'stories-dashboard#/editor-settings',
],
admin_url( 'edit.php' )
);
$user = wp_get_current_user();
/** This filter is documented in wp-admin/includes/post.php */
$show_locked_dialog = apply_filters( 'show_post_locked_dialog', true, $post, $user );
$nonce = wp_create_nonce( 'wp_rest' );
$story = new Story();
$story->load_from_post( $post );
// Explicitly setting these flags which became the default in PHP 8.1.
// Needed for correct single quotes in the editor & output.
// See https://github.com/GoogleForCreators/web-stories-wp/issues/10809.
$publisher_name = html_entity_decode( $story->get_publisher_name(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
$shopping_provider = $this->settings->get_setting( $this->settings::SETTING_NAME_SHOPPING_PROVIDER );
$auto_advance = $this->settings->get_setting( $this->settings::SETTING_NAME_AUTO_ADVANCE );
$page_duration = $this->settings->get_setting( $this->settings::SETTING_NAME_DEFAULT_PAGE_DURATION );
$auto_save_link = '';
if ( isset( $story_id ) ) {
$auto_save = wp_get_post_autosave( $story_id );
if ( $auto_save && $post ) {
if ( mysql2date( 'U', $auto_save->post_modified_gmt, false ) > mysql2date( 'U', $post->post_modified_gmt, false ) ) {
$auto_save_link = get_edit_post_link( $auto_save->ID );
} else {
wp_delete_post_revision( $auto_save->ID );
}
}
}
/**
* Revision.
*
* @var int $revision
*/
$revision = isset( $_GET['revision'] ) ? absint( $_GET['revision'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$revision_message = ! empty( $revision ) ?
sprintf(
/* translators: %s: Date and time of the revision. */
__( 'Story restored to revision from %s.', 'web-stories' ),
wp_post_revision_title( $revision, false )
)
: false;
$settings = [
'autoSaveInterval' => \defined( 'AUTOSAVE_INTERVAL' ) ? AUTOSAVE_INTERVAL : null,
'localAutoSaveInterval' => 15,
'autoSaveLink' => $auto_save_link,
'isRTL' => is_rtl(),
'locale' => $this->locale->get_locale_settings(),
'allowedMimeTypes' => $this->types->get_allowed_mime_types(),
'postType' => $this->story_post_type->get_slug(),
'storyId' => $story_id,
'dashboardLink' => $dashboard_url,
'revisionLink' => $revision_url,
'revisionMessage' => $revision_message,
'dashboardSettingsLink' => $dashboard_settings_url,
'generalSettingsLink' => $general_settings_url,
'cdnURL' => trailingslashit( WEBSTORIES_CDN_URL ),
'maxUpload' => $max_upload_size,
'editPostsCapabilityName' => $this->story_post_type->get_cap_name( 'edit_posts' ),
'capabilities' => [
'hasUploadMediaAction' => current_user_can( 'upload_files' ),
'canManageSettings' => current_user_can( 'manage_options' ),
],
'api' => [
'users' => '/web-stories/v1/users/',
'currentUser' => '/web-stories/v1/users/me/',
'stories' => trailingslashit( $this->story_post_type->get_rest_url() ),
'pageTemplates' => trailingslashit( $this->page_template_post_type->get_rest_url() ),
'media' => '/web-stories/v1/media/',
'hotlink' => '/web-stories/v1/hotlink/validate/',
'publisherLogos' => '/web-stories/v1/publisher-logos/',
'products' => '/web-stories/v1/products/',
'proxy' => rest_url( '/web-stories/v1/hotlink/proxy/' ),
'link' => '/web-stories/v1/link/',
'statusCheck' => '/web-stories/v1/status-check/',
'taxonomies' => '/web-stories/v1/taxonomies/',
'fonts' => trailingslashit( $this->font_post_type->get_rest_url() ),
'metaBoxes' => $this->meta_boxes->get_meta_box_url( (int) $story_id ),
'storyLocking' => rest_url( sprintf( '%s/%s/lock/', $this->story_post_type->get_rest_url(), $story_id ) ),
],
'metadata' => [
'publisher' => $publisher_name,
],
'postLock' => [
'interval' => 60,
'showLockedDialog' => $show_locked_dialog,
],
'canViewDefaultTemplates' => true,
'version' => WEBSTORIES_VERSION,
'nonce' => $nonce,
'showMedia3p' => true,
'globalAutoAdvance' => (bool) $auto_advance,
'globalPageDuration' => (float) $page_duration,
'shoppingProvider' => $shopping_provider,
'encodeMarkup' => $this->decoder->supports_decoding(),
'metaBoxes' => $this->meta_boxes->get_meta_boxes_per_location(),
'ffmpegCoreUrl' => trailingslashit( WEBSTORIES_CDN_URL ) . 'js/@ffmpeg/core@0.11.0/dist/ffmpeg-core.js',
'mediainfoUrl' => trailingslashit( WEBSTORIES_CDN_URL ) . 'js/mediainfo.js@0.1.9/dist/MediaInfoModule.wasm',
'flags' => array_merge(
$this->experiments->get_experiment_statuses( 'general' ),
$this->experiments->get_experiment_statuses( 'editor' ),
),
];
/**
* Filters settings passed to the web stories editor.
*
* @since 1.0.0
*
* @param array $settings Array of settings passed to web stories editor.
*/
return apply_filters( 'web_stories_editor_settings', $settings );
}
/**
* Setup up post lock.
*
* @since 1.5.0
*
* @param int $story_id Post id of story.
*/
protected function setup_lock( int $story_id ): void {
if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) {
return;
}
// Make sure these functions are loaded.
if (
! \function_exists( '\wp_check_post_lock' ) ||
! \function_exists( '\wp_set_post_lock' )
) {
require_once ABSPATH . 'wp-admin/includes/post.php';
}
// Check current lock.
$lock_user_id = wp_check_post_lock( $story_id );
if ( ! $lock_user_id ) {
// If no lock set, create new lock.
wp_set_post_lock( $story_id );
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
/**
* Class Google_Fonts.
*
* Registers Google fonts for admin screens.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Infrastructure\Conditional;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use WP_Styles;
/**
* Class Google_Fonts
*
* Enqueue Google Fonts stylesheet.
*/
class Google_Fonts implements Conditional, Service, Registerable {
/**
* Script handle.
*/
public const SCRIPT_HANDLE = 'web-stories-fonts';
/**
* Check whether the conditional object is currently needed.
*
* @since 1.8.0
*
* @return bool Whether the conditional object is needed.
*/
public static function is_needed(): bool {
return is_admin() && ! wp_doing_ajax();
}
/**
* Runs on instantiation.
*
* @since 1.8.0
*/
public function register(): void {
add_action( 'wp_default_styles', [ $this, 'register_style' ] );
}
/**
* Registers the google font style.
*
* @since 1.8.0
*
* @param WP_Styles $wp_styles WP_Styles instance.
*/
public function register_style( WP_Styles $wp_styles ): void {
// so we need to avoid specifying a version at all.
$wp_styles->add(
self::SCRIPT_HANDLE,
'https://fonts.googleapis.com/css?family=Google+Sans|Google+Sans:b|Google+Sans:500&display=swap',
[],
WEBSTORIES_VERSION
);
}
}

View File

@@ -0,0 +1,191 @@
<?php
/**
* Class Meta_Boxes.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Story_Post_Type;
/**
* Class Meta_Boxes.
*/
class Meta_Boxes extends Service_Base {
/**
* Supported meta box locations.
*/
public const LOCATIONS = [ 'normal', 'advanced', 'side' ];
/**
* Meta box priorities.
*/
public const PRIORITIES = [ 'high', 'sorted', 'core', 'default', 'low' ];
/**
* Init.
*
* @since 1.2.0
*/
public function register(): void {
add_action( 'add_meta_boxes_' . Story_Post_Type::POST_TYPE_SLUG, [ $this, 'remove_meta_boxes' ], PHP_INT_MAX );
}
/**
* Removes all meta boxes with '__back_compat_meta_box' set to 'true'.
*
* This removes things like the post author meta box, as this feature is
* already included in the editor.
*
* Mimics what do_meta_boxes() does for the block editor.
*
* @since 1.2.0
*
* @see do_meta_boxes()
*/
public function remove_meta_boxes(): void {
global $wp_meta_boxes;
$screen = get_current_screen();
if ( ! $screen ) {
return;
}
foreach ( self::LOCATIONS as $context ) {
if ( ! isset( $wp_meta_boxes[ $screen->id ][ $context ] ) ) {
continue;
}
foreach ( self::PRIORITIES as $priority ) {
if ( ! isset( $wp_meta_boxes[ $screen->id ][ $context ][ $priority ] ) ) {
continue;
}
foreach ( (array) $wp_meta_boxes[ $screen->id ][ $context ][ $priority ] as $meta_box ) {
if ( false === $meta_box || ! $meta_box['title'] ) {
continue;
}
if (
// We don't currently support the 'Custom Fields' meta box.
'postcustom' === $meta_box['id'] ||
( \is_array( $meta_box['args'] ) && ! empty( $meta_box['args']['__back_compat_meta_box'] ) )
) {
remove_meta_box( $meta_box['id'], $screen, $context );
}
}
}
}
}
/**
* Returns the admin URL for handling meta boxes.
*
* @since 1.2.0
*
* @param int $story_id Story ID.
* @return string Meta box URL.
*/
public function get_meta_box_url( int $story_id ): string {
$meta_box_url = admin_url( 'post.php' );
$meta_box_url = add_query_arg(
[
'post' => $story_id,
'action' => 'edit',
'meta-box-loader' => true,
'meta-box-loader-nonce' => wp_create_nonce( 'meta-box-loader' ),
],
$meta_box_url
);
return $meta_box_url;
}
/**
* Returns list of custom meta boxes per location.
*
* Used to disable empty meta boxes in the editor.
*
* @since 1.2.0
*
* @see the_block_editor_meta_boxes()
*
* @return array<string, array<int, array{id: string, title: string}>> List of meta boxes per location.
*/
public function get_meta_boxes_per_location(): array {
global $wp_meta_boxes;
$screen = get_current_screen();
if ( ! $screen ) {
return [];
}
$_wp_meta_boxes = $wp_meta_boxes ?? [];
/**
* Filters meta box data before making it available to the editor.
*
* This allows for the modifications of meta boxes that are already
* present by this point. Do not use as a means of adding meta box data.
*
* @since 1.3.0
*
* @param array $wp_meta_boxes Global meta box state.
*/
$_wp_meta_boxes = apply_filters( 'web_stories_editor_meta_boxes', $_wp_meta_boxes );
$meta_boxes_per_location = [];
foreach ( self::LOCATIONS as $context ) {
$meta_boxes_per_location[ $context ] = [];
if ( ! isset( $_wp_meta_boxes[ $screen->id ][ $context ] ) ) {
continue;
}
foreach ( self::PRIORITIES as $priority ) {
if ( ! isset( $_wp_meta_boxes[ $screen->id ][ $context ][ $priority ] ) ) {
continue;
}
$meta_boxes = (array) $_wp_meta_boxes[ $screen->id ][ $context ][ $priority ];
foreach ( $meta_boxes as $meta_box ) {
if ( false === $meta_box || ! $meta_box['title'] ) {
continue;
}
$meta_boxes_per_location[ $context ][] = [
'id' => $meta_box['id'],
'title' => $meta_box['title'],
];
}
}
}
return $meta_boxes_per_location;
}
}

View File

@@ -0,0 +1,87 @@
<?php
/**
* PluginActionLinks class.
*
* Updates the plugin action links for the plugin.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Story_Post_Type;
/**
* Updates the plugin action links for the plugin.
*/
class PluginActionLinks extends Service_Base {
/**
* Runs on instantiation.
*
* @since 1.6.0
*/
public function register(): void {
$basename = plugin_basename( WEBSTORIES_PLUGIN_FILE );
add_filter( 'plugin_action_links_' . $basename, [ $this, 'action_links' ] );
}
/**
* Get the action to use for registering the service.
*
* @since 1.6.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'admin_init';
}
/**
* Add action link to plugin settings page.
*
* @since 1.6.0
*
* @param array|mixed $links Plugin action links.
* @return array|mixed
*
* @template T
*
* @phpstan-return ($links is array<T> ? array<T> : mixed)
*/
public function action_links( $links ) {
if ( ! \is_array( $links ) ) {
return $links;
}
$slug = sprintf( 'edit.php?post_type=%s&page=stories-dashboard#/editor-settings', Story_Post_Type::POST_TYPE_SLUG );
$url = get_admin_url( null, $slug );
$links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $url ),
esc_html__( 'Settings', 'web-stories' )
);
return $links;
}
}

View File

@@ -0,0 +1,88 @@
<?php
/**
* PluginRowMeta class.
*
* Updates the plugin row meta for the plugin.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Service_Base;
/**
* Updates the plugin row meta for the plugin.
*/
class PluginRowMeta extends Service_Base {
/**
* Runs on instantiation.
*
* @since 1.6.0
*/
public function register(): void {
add_filter( 'plugin_row_meta', [ $this, 'get_plugin_row_meta' ], 10, 2 );
}
/**
* Get the action to use for registering the service.
*
* @since 1.6.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'admin_init';
}
/**
* Updates the plugin row meta with links to review plugin and get support.
*
* @since 1.6.0
*
* @param string[]|mixed $meta An array of the plugin's metadata, including the version, author, author URI,
* and plugin URI.
* @param string $plugin_file Path to the plugin file relative to the plugins directory.
* @return string[]|mixed Plugin row meta.
*
* @template T
*
* @phpstan-return ($meta is array<T> ? array<T> : mixed)
*/
public function get_plugin_row_meta( $meta, string $plugin_file ) {
if ( plugin_basename( WEBSTORIES_PLUGIN_FILE ) !== $plugin_file ) {
return $meta;
}
if ( ! \is_array( $meta ) ) {
return $meta;
}
$additional_meta = [
'<a href="https://wordpress.org/support/plugin/web-stories/" target="_blank" rel="noreferrer noopener">' . esc_html__( 'Contact support', 'web-stories' ) . '</a>',
'<a href="https://wordpress.org/support/plugin/web-stories/reviews/#new-post" target="_blank" rel="noreferrer noopener">' . esc_html__( 'Leave review', 'web-stories' ) . '</a>',
];
return [ ...$meta, ...$additional_meta ];
}
}

View File

@@ -0,0 +1,259 @@
<?php
/**
* Site Health Class.
*
* Adds tests and debugging information for Site Health.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Experiments;
use Google\Web_Stories\Infrastructure\Conditional;
use Google\Web_Stories\Service_Base;
/**
* Class Site_Health
*/
class Site_Health extends Service_Base implements Conditional {
/**
* Experiments instance.
*
* @var Experiments Experiments instance.
*/
private Experiments $experiments;
/**
* Site_Health constructor.
*
* @since 1.8.0
*
* @param Experiments $experiments Experiments instance.
* @return void
*/
public function __construct( Experiments $experiments ) {
$this->experiments = $experiments;
}
/**
* Check whether the conditional object is currently needed.
*
* @since 1.8.0
*
* @return bool Whether the conditional object is needed.
*/
public static function is_needed(): bool {
return is_admin() && ! wp_doing_ajax();
}
/**
* Get the action to use for registering the service.
*
* @since 1.8.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'wp_loaded';
}
/**
* Adds the filters.
*
* @since 1.8.0
*/
public function register(): void {
add_filter( 'debug_information', [ $this, 'add_debug_information' ] );
add_filter( 'site_status_test_php_modules', [ $this, 'add_extensions' ] );
add_filter( 'site_status_test_result', [ $this, 'modify_test_result' ] );
}
/**
* Adds debug information for Web stories.
*
* @since 1.8.0
*
* @param array|mixed $debugging_information The debugging information from Core.
* @return array|mixed The debugging information, with added information for Web stories.
*
* @template T
*
* @phpstan-return ($debugging_information is array<T> ? array<T> : mixed)
*/
public function add_debug_information( $debugging_information ) {
$enabled_experiments = [];
foreach ( $this->experiments->get_experiments() as $experiment ) {
$enabled = $this->experiments->is_experiment_enabled( $experiment['name'] );
if ( $enabled ) {
$enabled_experiments[ $experiment['label'] ] = $this->get_formatted_output( $enabled );
}
}
if ( ! $enabled_experiments ) {
$enabled_experiments = __( 'No experiments enabled', 'web-stories' );
}
if ( ! \is_array( $debugging_information ) ) {
return $debugging_information;
}
$extra_data = [
'web_stories' => [
'label' => esc_html__( 'Web Stories', 'web-stories' ),
'description' => esc_html__( 'Debugging information for the Web Stories plugin.', 'web-stories' ),
'fields' => [
'web_stories_version' => [
'label' => 'WEBSTORIES_VERSION',
'value' => WEBSTORIES_VERSION,
'private' => false,
],
'web_stories_db_version' => [
'label' => 'WEBSTORIES_DB_VERSION',
'value' => WEBSTORIES_DB_VERSION,
'private' => false,
],
'web_stories_amp_version' => [
'label' => 'WEBSTORIES_AMP_VERSION',
'value' => WEBSTORIES_AMP_VERSION,
'private' => false,
],
'web_stories_cdn_url' => [
'label' => 'WEBSTORIES_CDN_URL',
'value' => WEBSTORIES_CDN_URL,
'private' => false,
],
'web_stories_dev_mode' => [
'label' => 'WEBSTORIES_DEV_MODE',
'private' => false,
'value' => $this->get_formatted_output( WEBSTORIES_DEV_MODE ),
'debug' => WEBSTORIES_DEV_MODE,
],
'web_stories_theme_support' => [
'label' => 'Theme supports',
'value' => $this->get_formatted_output( current_theme_supports( 'web-stories' ) ),
'private' => false,
],
'web_stories_enabled_experiments' => [
'label' => 'Experiments',
'value' => $enabled_experiments,
'private' => false,
],
'web_stories_libxml_version' => [
'label' => 'libxml Version',
'value' => LIBXML_DOTTED_VERSION,
'private' => false,
],
],
],
];
return array_merge( $debugging_information, $extra_data );
}
/**
* Adds suggested PHP extensions to those that Core depends on.
*
* @since 1.8.0
*
* @param array|mixed $core_extensions The existing extensions from Core.
* @return array|mixed The extensions, including those for Web Stories.
*
* @template T
*
* @phpstan-return ($core_extensions is array<T> ? array<T> : mixed)
*/
public function add_extensions( $core_extensions ) {
if ( ! \is_array( $core_extensions ) ) {
return $core_extensions;
}
$extensions = [
'json' => [
'extension' => 'json',
'function' => 'json_encode',
'required' => false,
],
'libxml' => [
'extension' => 'libxml',
'function' => 'libxml_use_internal_errors',
'required' => false,
],
'date' => [
'extension' => 'date',
'class' => 'DateTimeImmutable',
'required' => false,
],
'dom' => [
'extension' => 'dom',
'class' => 'DOMNode',
'required' => false,
],
'mbstring' => [
'extension' => 'mbstring',
'required' => false,
],
'spl' => [
'function' => 'spl_autoload_register',
'required' => false,
],
];
return array_merge( $core_extensions, $extensions );
}
/**
* Modify test results.
*
* @since 1.8.0
*
* @param array|mixed $test_result Site Health test result.
* @return array|mixed Modified test result.
*
* @template T
*
* @phpstan-return ($test_result is array<T> ? array<T> : mixed)
*/
public function modify_test_result( $test_result ) {
if ( ! \is_array( $test_result ) ) {
return $test_result;
}
// Set the `https_status` test status to critical if its current status is recommended, along with adding to the
// description for why its required for Web Stories.
if ( isset( $test_result['test'], $test_result['status'], $test_result['description'] ) && 'https_status' === $test_result['test'] && 'recommended' === $test_result['status'] ) {
$test_result['status'] = 'critical';
$test_result['description'] .= '<p>' . __( 'Additionally, Web Stories requires HTTPS for most components to work properly, including iframes and videos.', 'web-stories' ) . '</p>';
}
return $test_result;
}
/**
* Format the value as enabled or disabled.
*
* @since 1.8.0
*
* @param mixed $value Value to formatted.
*/
protected function get_formatted_output( $value ): string {
return $value ? __( 'Enabled', 'web-stories' ) : __( 'Disabled', 'web-stories' );
}
}

View File

@@ -0,0 +1,216 @@
<?php
/**
* TinyMCE Class.
*
* Necessary operations for classic editor compatibility.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Assets;
use Google\Web_Stories\Context;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Stories_Script_Data;
/**
* Class TinyMCE
*/
class TinyMCE extends Service_Base {
/**
* Web Stories tinymce script handle.
*/
public const SCRIPT_HANDLE = 'web-stories-tinymce-button';
/**
* Assets instance.
*
* @var Assets Assets instance.
*/
private Assets $assets;
/**
* Stories_Script_Data instance.
*
* @var Stories_Script_Data Stories_Script_Data instance.
*/
protected Stories_Script_Data $stories_script_data;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Tinymce constructor.
*
* @since 1.8.0
*
* @param Assets $assets Assets instance.
* @param Stories_Script_Data $stories_script_data Stories_Script_Data instance.
* @param Context $context Context instance.
*/
public function __construct( Assets $assets, Stories_Script_Data $stories_script_data, Context $context ) {
$this->assets = $assets;
$this->stories_script_data = $stories_script_data;
$this->context = $context;
}
/**
* Initialization actions.
*
* @since 1.5.0
*/
public function register(): void {
if ( $this->context->is_block_editor() || $this->context->is_story_editor() ) {
return;
}
$this->register_assets();
add_action( 'wp_enqueue_editor', [ $this, 'enqueue_assets' ] );
add_filter( 'mce_buttons', [ $this, 'tinymce_web_stories_button' ] );
add_filter( 'mce_external_plugins', [ $this, 'web_stories_mce_plugin' ] );
add_action( 'admin_footer', [ $this, 'web_stories_tinymce_root_element' ] );
add_filter( 'script_loader_tag', [ $this, 'script_loader_tag' ], 10, 3 );
}
/**
* Get the action to use for registering the service.
*
* @since 1.6.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'admin_enqueue_scripts';
}
/**
* Add web stories button in TinyMCE editor.
*
* @since 1.5.0
*
* @param array|mixed $buttons Array of TinyMCE buttons.
* @return array|mixed
*
* @template T
*
* @phpstan-return ($buttons is array<T> ? array<T> : mixed)
*/
public function tinymce_web_stories_button( $buttons ) {
if ( ! \is_array( $buttons ) ) {
return $buttons;
}
$buttons[] = 'web_stories';
return $buttons;
}
/**
* Register web stories plugin for tinycemce editor.
*
* @since 1.5.0
*
* @param array|mixed $plugins Array of TinyMCE plugin scripts.
* @return array|mixed
*
* @template T
*
* @phpstan-return ($plugins is array<T> ? array<T> : mixed)
*/
public function web_stories_mce_plugin( $plugins ) {
if ( ! \is_array( $plugins ) ) {
return $plugins;
}
$plugins['web_stories'] = $this->assets->get_base_url( sprintf( 'assets/js/%s.js', self::SCRIPT_HANDLE ) );
return $plugins;
}
/**
* Enqueue related scripts.
*
* @since 1.5.0
*/
public function register_assets(): void {
$this->assets->enqueue_style( 'wp-components' );
$this->assets->enqueue_script_asset( self::SCRIPT_HANDLE );
wp_localize_script(
self::SCRIPT_HANDLE,
'webStoriesData',
$this->stories_script_data->get_script_data()
);
}
/**
* Hijack the button's script to render an empty script tag.
*
* @since 1.5.0
*
* @param string|mixed $tag The `<script>` tag for the enqueued script.
* @param string $handle The script's registered handle.
* @param string $src The script's source URL.
* @return string|mixed The filtered script tag.
*/
public function script_loader_tag( $tag, string $handle, string $src ) {
if ( ! \is_string( $tag ) ) {
return $tag;
}
if ( self::SCRIPT_HANDLE === $handle ) {
$tag = str_replace( $src, '', $tag );
// phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- False positive.
$tag = (string) preg_replace( '#<script src=\'\'(.*?)>(.*?)</script>#is', '', $tag );
}
return $tag;
}
/**
* Enqueue related scripts.
*
* @since 1.5.0
*/
public function enqueue_assets(): void {
$this->assets->enqueue_style( 'wp-components' );
$this->assets->enqueue_script_asset( self::SCRIPT_HANDLE );
}
/**
* Root element for tinymce editor.
* This is useful for performing some react operations.
*
* @since 1.5.0
*/
public function web_stories_tinymce_root_element(): void {
?>
<div id="web-stories-tinymce"></div>
<?php
}
}

View File

@@ -0,0 +1,285 @@
<?php
/**
* Class Analytics
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
/**
* Class Analytics
*/
class Analytics extends Service_Base {
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Analytics constructor.
*
* @since 1.12.0
*
* @param Settings $settings Settings instance.
* @return void
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Initializes all hooks.
*
* @since 1.0.0
*/
public function register(): void {
add_action( 'web_stories_print_analytics', [ $this, 'print_analytics_tag' ] );
}
/**
* Returns the Google Analytics tracking ID.
*
* @since 1.0.0
*
* @return string Tracking ID.
*/
public function get_tracking_id(): string {
/**
* Tracking ID.
*
* @var string $tracking_id
*/
$tracking_id = $this->settings->get_setting( $this->settings::SETTING_NAME_TRACKING_ID );
// For some reasons, some sites use the plugin's own tracking ID (as used in the admin)
// for their stories. Prevent accidental erroneous tracking in such a case.
if ( Tracking::TRACKING_ID === $tracking_id ) {
return '';
}
return $tracking_id;
}
/**
* Returns the default analytics configuration.
*
* Note: variables in single quotes will be substituted by <amp-analytics>.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @see https://github.com/ampproject/amphtml/blob/main/docs/spec/amp-var-substitutions.md
*
* @param string $tracking_id Tracking ID.
* @return array<string, array<string, mixed>> <amp-analytics> configuration.
*/
public function get_default_configuration( string $tracking_id ): array {
$config = [
'vars' => [
'gtag_id' => $tracking_id,
'config' => [
$tracking_id => [ 'groups' => 'default' ],
],
],
'triggers' => [
// Fired when a story page becomes visible.
'storyProgress' => [
'on' => 'story-page-visible',
'request' => 'event',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_progress',
'event_category' => '${title}',
'event_label' => '${storyPageIndex}',
'event_value' => '${storyProgress}',
'send_to' => $tracking_id,
],
],
// Fired when the last page in the story is shown to the user.
// This can be used to measure completion rate.
'storyEnd' => [
'on' => 'story-last-page-visible',
'request' => 'event',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_complete',
'event_category' => '${title}',
'event_label' => '${storyPageCount}',
'send_to' => $tracking_id,
],
],
// Fired when clicking an element that opens a tooltip (<a> or <amp-twitter>).
'trackFocusState' => [
'on' => 'story-focus',
'tagName' => 'a',
'request' => 'click ',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_focus',
'event_category' => '${title}',
'send_to' => $tracking_id,
],
],
// Fired when clicking on a tooltip.
'trackClickThrough' => [
'on' => 'story-click-through',
'tagName' => 'a',
'request' => 'click ',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_click_through',
'event_category' => '${title}',
'send_to' => $tracking_id,
],
],
// Fired when opening a drawer or dialog inside a story (e.g. page attachment).
'storyOpen' => [
'on' => 'story-open',
'request' => 'event',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_open',
'event_category' => '${title}',
'send_to' => $tracking_id,
],
],
// Fired when closing a drawer or dialog inside a story (e.g. page attachment).
'storyClose' => [
'on' => 'story-close',
'request' => 'event',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_close',
'event_category' => '${title}',
'send_to' => $tracking_id,
],
],
// Fired when the user initiates an interaction to mute the audio for the current story.
'audioMuted' => [
'on' => 'story-audio-muted',
'request' => 'event',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_audio_muted',
'event_category' => '${title}',
'send_to' => $tracking_id,
],
],
// Fired when the user initiates an interaction to unmute the audio for the current story.
'audioUnmuted' => [
'on' => 'story-audio-unmuted',
'request' => 'event',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_audio_unmuted',
'event_category' => '${title}',
'send_to' => $tracking_id,
],
],
// Fired when a page attachment is opened by the user.
'pageAttachmentEnter' => [
'on' => 'story-page-attachment-enter',
'request' => 'event',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_page_attachment_enter',
'event_category' => '${title}',
'send_to' => $tracking_id,
],
],
// Fired when a page attachment is dismissed by the user.
'pageAttachmentExit' => [
'on' => 'story-page-attachment-exit',
'request' => 'event',
'vars' => [
'event_name' => 'custom',
'event_action' => 'story_page_attachment_exit',
'event_category' => '${title}',
'send_to' => $tracking_id,
],
],
],
];
/**
* Filters the Web Stories Google Analytics configuration.
*
* Only used when not using <amp-story-auto-analytics>, which is the default.
*
* @param array $config Analytics configuration.
*/
return apply_filters( 'web_stories_analytics_configuration', $config );
}
/**
* Prints the analytics tag for single stories.
*
* @since 1.0.0
*/
public function print_analytics_tag(): void {
$tracking_id = $this->get_tracking_id();
if ( ! $tracking_id ) {
return;
}
if ( $this->settings->get_setting( $this->settings::SETTING_NAME_USING_LEGACY_ANALYTICS ) ) {
$this->print_amp_analytics_tag( $tracking_id );
} else {
$this->print_amp_story_auto_analytics_tag( $tracking_id );
}
}
/**
* Prints the legacy <amp-analytics> tag for single stories.
*
* @since 1.12.0
*
* @param string $tracking_id Tracking ID.
*/
private function print_amp_analytics_tag( string $tracking_id ): void {
?>
<amp-analytics type="gtag" data-credentials="include">
<script type="application/json">
<?php echo wp_json_encode( $this->get_default_configuration( $tracking_id ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</script>
</amp-analytics>
<?php
}
/**
* Prints the <amp-story-auto-analytics> tag for single stories.
*
* @since 1.12.0
*
* @param string $tracking_id Tracking ID.
*/
private function print_amp_story_auto_analytics_tag( string $tracking_id ): void {
?>
<amp-story-auto-analytics gtag-id="<?php echo esc_attr( $tracking_id ); ?>"></amp-story-auto-analytics>
<?php
}
}

View File

@@ -0,0 +1,414 @@
<?php
/**
* Class Assets
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
/**
* Class Assets
*
* @phpstan-type AssetMetadata array{
* version: string,
* dependencies: string[],
* js: string[],
* css: string[],
* chunks: string[],
* }
*/
class Assets {
/**
* An array of registered styles.
*
* @var array<string, bool>
*/
protected array $register_styles = [];
/**
* An array of registered scripts.
*
* @var array<string, bool>
*/
protected array $register_scripts = [];
/**
* Get path to file and directory.
*
* @since 1.8.0
*
* @param string $path Path.
*/
public function get_base_path( string $path ): string {
return WEBSTORIES_PLUGIN_DIR_PATH . $path;
}
/**
* Get url of file and directory.
*
* @since 1.8.0
*
* @param string $path Path.
*/
public function get_base_url( string $path ): string {
return WEBSTORIES_PLUGIN_DIR_URL . $path;
}
/**
* Get asset metadata.
*
* @since 1.8.0
*
* @param string $handle Script handle.
* @return array Array containing combined contents of "<$handle>.asset.php" and "<$handle>.chunks.php".
*
* @phpstan-return AssetMetadata
*/
public function get_asset_metadata( string $handle ): array {
$base_path = $this->get_base_path( 'assets/js/' );
// *.asset.php is generated by DependencyExtractionWebpackPlugin.
// *.chunks.php is generated by HtmlWebpackPlugin with a custom template.
$asset_file = $base_path . $handle . '.asset.php';
$chunks_file = $base_path . $handle . '.chunks.php';
// phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable
$asset = is_readable( $asset_file ) ? require $asset_file : [];
// phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable
$chunks = is_readable( $chunks_file ) ? require $chunks_file : [];
// A hash calculated based on the file content of the entry point bundle at <$handle>.js.
$asset['version'] ??= WEBSTORIES_VERSION;
$asset['dependencies'] ??= [];
$asset['js'] = $chunks['js'] ?? [];
$asset['css'] = $chunks['css'] ?? [];
$asset['chunks'] = $chunks['chunks'] ?? [];
return $asset;
}
/**
* Register script using handle.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.8.0
*
* @param string $script_handle Handle of script.
* @param string[] $script_dependencies Array of extra dependencies.
* @param bool $with_i18n Optional. Whether to setup i18n for this asset. Default true.
*/
public function register_script_asset( string $script_handle, array $script_dependencies = [], bool $with_i18n = true ): void {
if ( isset( $this->register_scripts[ $script_handle ] ) ) {
return;
}
$base_script_path = $this->get_base_url( 'assets/js/' );
$in_footer = true;
$asset = $this->get_asset_metadata( $script_handle );
$entry_version = $asset['version'];
// Register any chunks of $script_handle first.
// `$asset['js']` are preloaded chunks, `$asset['chunks']` dynamically imported ones.
foreach ( $asset['js'] as $chunk ) {
$this->register_script(
$chunk,
$base_script_path . $chunk . '.js',
[],
$entry_version,
$in_footer,
$with_i18n
);
}
// Dynamically imported chunks MUST NOT be added as dependencies here.
$dependencies = [ ...$asset['dependencies'], ...$script_dependencies, ...$asset['js'] ];
$this->register_script(
$script_handle,
$base_script_path . $script_handle . '.js',
$dependencies,
$entry_version,
$in_footer,
$with_i18n
);
// "Save" all the script's chunks so we can later manually fetch them and their translations if needed.
wp_script_add_data( $script_handle, 'chunks', $asset['chunks'] );
// Register every dynamically imported chunk as a script, just so
// that we can print their translations whenever the main script is enqueued.
// The actual enqueueing of these chunks is done by the main script via dynamic imports.
foreach ( $asset['chunks'] as $dynamic_chunk ) {
$this->register_script(
$dynamic_chunk,
$base_script_path . $dynamic_chunk . '.js',
[],
$entry_version, // Not actually used / relevant, since enqueueing is done by webpack.
$in_footer, // Ditto.
$with_i18n
);
if ( $with_i18n ) {
wp_add_inline_script( $script_handle, (string) wp_scripts()->print_translations( $dynamic_chunk, false ) );
}
}
}
/**
* Enqueue script using handle.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.8.0
*
* @param string $script_handle Handle of script.
* @param string[] $script_dependencies Array of extra dependencies.
* @param bool $with_i18n Optional. Whether to setup i18n for this asset. Default true.
*/
public function enqueue_script_asset( string $script_handle, array $script_dependencies = [], bool $with_i18n = true ): void {
$this->register_script_asset( $script_handle, $script_dependencies, $with_i18n );
$this->enqueue_script( $script_handle );
}
/**
* Register style using handle.
*
* @since 1.8.0
*
* @param string $style_handle Handle of style.
* @param string[] $style_dependencies Array of extra dependencies.
*/
public function register_style_asset( string $style_handle, array $style_dependencies = [] ): void {
if ( isset( $this->register_styles[ $style_handle ] ) ) {
return;
}
$base_style_url = $this->get_base_url( 'assets/css/' );
$base_style_path = $this->get_base_path( 'assets/css/' );
$ext = is_rtl() ? '-rtl.css' : '.css';
// Register any chunks of $style_handle first.
$asset = $this->get_asset_metadata( $style_handle );
// Webpack appends "-[contenthash]" to filenames of chunks, so omit the `?ver=` query param.
$chunk_version = null;
foreach ( $asset['css'] as $style_chunk ) {
$this->register_style(
$style_chunk,
$base_style_url . $style_chunk . '.css',
[],
$chunk_version
);
wp_style_add_data( $style_chunk, 'path', $base_style_path . $style_chunk . $ext );
}
$style_dependencies = [ ...$style_dependencies, ...$asset['css'] ];
$entry_version = $asset['version'];
$this->register_style(
$style_handle,
$base_style_url . $style_handle . '.css',
$style_dependencies,
$entry_version
);
wp_style_add_data( $style_handle, 'rtl', 'replace' );
wp_style_add_data( $style_handle, 'path', $base_style_path . $style_handle . $ext );
}
/**
* Enqueue style using handle.
*
* @since 1.8.0
*
* @param string $style_handle Handle of style.
* @param string[] $style_dependencies Array of extra dependencies.
*/
public function enqueue_style_asset( string $style_handle, array $style_dependencies = [] ): void {
$this->register_style_asset( $style_handle, $style_dependencies );
$this->enqueue_style( $style_handle );
}
/**
* Register a CSS stylesheet.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.8.0
*
* @param string $style_handle Name of the stylesheet. Should be unique.
* @param string|false $src Full URL of the stylesheet, or path of the stylesheet relative to the WordPress root directory.
* If source is set to false, stylesheet is an alias of other stylesheets it depends on.
* @param string[] $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array.
* @param string|bool|null $ver Optional. String specifying stylesheet version number, if it has one, which is added to the URL
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param string $media Optional. The media for which this stylesheet has been defined.
* Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media queries like
* '(orientation: portrait)' and '(max-width: 640px)'.
* @return bool Whether the style has been registered. True on success, false on failure.
*/
public function register_style( string $style_handle, $src, array $deps = [], $ver = false, string $media = 'all' ): bool {
if ( ! isset( $this->register_styles[ $style_handle ] ) ) {
$this->register_styles[ $style_handle ] = wp_register_style( $style_handle, $src, $deps, $ver, $media );
}
return $this->register_styles[ $style_handle ];
}
/**
* Register a new script.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.8.0
*
* @param string $script_handle Name of the script. Should be unique.
* @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory.
* If source is set to false, script is an alias of other scripts it depends on.
* @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array.
* @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param bool $in_footer Optional. Whether to enqueue the script before </body> instead of in the <head>.
* Default 'false'.
* @param bool $with_i18n Optional. Whether to setup i18n for this asset. Default true.
* @return bool Whether the script has been registered. True on success, false on failure.
*/
public function register_script( string $script_handle, $src, array $deps = [], $ver = false, bool $in_footer = false, bool $with_i18n = true ): bool {
if ( ! isset( $this->register_scripts[ $script_handle ] ) ) {
$this->register_scripts[ $script_handle ] = wp_register_script( $script_handle, $src, $deps, $ver, $in_footer );
if ( $src && $with_i18n ) {
wp_set_script_translations( $script_handle, 'web-stories' );
}
}
return $this->register_scripts[ $script_handle ];
}
/**
* Enqueue a style.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.8.0
*
* @param string $style_handle Name of the stylesheet. Should be unique.
* @param string $src Full URL of the stylesheet, or path of the stylesheet relative to the WordPress root directory.
* Default empty.
* @param string[] $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array.
* @param string|bool|null $ver Optional. String specifying stylesheet version number, if it has one, which is added to the URL
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param string $media Optional. The media for which this stylesheet has been defined.
* Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media queries like
* '(orientation: portrait)' and '(max-width: 640px)'.
*/
public function enqueue_style( string $style_handle, string $src = '', array $deps = [], $ver = false, string $media = 'all' ): void {
$this->register_style( $style_handle, $src, $deps, $ver, $media );
wp_enqueue_style( $style_handle, $src, $deps, $ver, $media );
}
/**
* Enqueue a script.
*
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*
* @since 1.8.0
*
* @param string $script_handle Name of the script. Should be unique.
* @param string $src Full URL of the script, or path of the script relative to the WordPress root directory.
* Default empty.
* @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array.
* @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param bool $in_footer Optional. Whether to enqueue the script before </body> instead of in the <head>.
* Default 'false'.
* @param bool $with_i18n Optional. Whether to setup i18n for this asset. Default true.
*/
public function enqueue_script( string $script_handle, string $src = '', array $deps = [], $ver = false, bool $in_footer = false, bool $with_i18n = false ): void {
$this->register_script( $script_handle, $src, $deps, $ver, $in_footer, $with_i18n );
wp_enqueue_script( $script_handle, $src, $deps, $ver, $in_footer );
}
/**
* Remove admin styles.
*
* @since 1.8.0
*
* @param string[] $styles Array of styles to be removed.
*/
public function remove_admin_style( array $styles ): void {
wp_styles()->registered['wp-admin']->deps = array_diff( wp_styles()->registered['wp-admin']->deps, $styles );
}
/**
* Returns the translations for a script and all of its chunks.
*
* @since 1.14.0
*
* @param string $script_handle Name of the script. Should be unique.
* @return array<int, mixed> Script translations.
*/
public function get_translations( string $script_handle ): array {
/**
* List of script chunks.
*
* @var false|string[]
*/
$chunks = wp_scripts()->get_data( $script_handle, 'chunks' );
if ( ! \is_array( $chunks ) ) {
return [];
}
$translations = [
(string) load_script_textdomain( $script_handle, 'web-stories' ),
];
/**
* Dynamic chunk name.
*
* @var string $dynamic_chunk
*/
foreach ( $chunks as $dynamic_chunk ) {
$translations[] = (string) load_script_textdomain( $dynamic_chunk, 'web-stories' );
}
return array_values(
array_map(
static fn( $translations ) => json_decode( $translations, true ),
array_filter( $translations )
)
);
}
}

View File

@@ -0,0 +1,451 @@
<?php
/**
* Class Web_Stories_Block.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Block;
use Google\Web_Stories\AMP_Story_Player_Assets;
use Google\Web_Stories\Assets;
use Google\Web_Stories\Context;
use Google\Web_Stories\Embed_Base;
use Google\Web_Stories\Model\Story;
use Google\Web_Stories\Stories_Script_Data;
use Google\Web_Stories\Story_Post_Type;
use Google\Web_Stories\Story_Query;
use Google\Web_Stories\Tracking;
use WP_Block;
/**
* Latest Stories block class.
*
* @phpstan-import-type StoryAttributes from \Google\Web_Stories\Story_Query
* @phpstan-type BlockAttributes array{
* blockType?: string,
* url?: string,
* title?: string,
* poster?: string,
* width?: int,
* height?: int,
* align?: string,
* stories?: int[],
* viewType?: string,
* numOfStories?: int,
* numOfColumns?: int,
* circleSize?: int,
* imageAlignment?: string,
* orderby?: string,
* order?: string,
* archiveLinkLabel?: string,
* authors?: int[],
* fieldState?: array<string, mixed>,
* previewOnly?: bool,
* taxQuery?: array<string, int[]>
* }
* @phpstan-type BlockAttributesWithDefaults array{
* blockType?: string,
* url?: string,
* title?: string,
* poster?: string,
* width?: int,
* height?: int,
* align: string,
* stories?: int[],
* viewType?: string,
* numOfStories?: int,
* numOfColumns?: int,
* circleSize?: int,
* imageAlignment?: string,
* orderby?: string,
* order?: string,
* archiveLinkLabel?: string,
* authors?: int[],
* fieldState?: array<string, mixed>,
* previewOnly?: bool
* }
*/
class Web_Stories_Block extends Embed_Base {
/**
* Script handle.
*/
public const SCRIPT_HANDLE = 'web-stories-block';
/**
* Maximum number of stories users can select
*/
public const MAX_NUM_OF_STORIES = 20;
/**
* Current block's block attributes.
*
* @var array<string, mixed> Block Attributes.
* @phpstan-var BlockAttributes
*/
protected array $block_attributes;
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
protected Story_Post_Type $story_post_type;
/**
* Stories_Script_Data instance.
*
* @var Stories_Script_Data Stories_Script_Data instance.
*/
protected Stories_Script_Data $stories_script_data;
/**
* Embed Base constructor.
*
* @since 1.14.0
*
* @param Assets $assets Assets instance.
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Stories_Script_Data $stories_script_data Stories_Script_Data instance.
* @param Context $context Context instance.
*/
public function __construct(
Assets $assets,
Story_Post_Type $story_post_type,
Stories_Script_Data $stories_script_data,
Context $context
) {
parent::__construct( $assets, $context );
$this->story_post_type = $story_post_type;
$this->stories_script_data = $stories_script_data;
}
/**
* Initializes the Web Stories embed block.
*
* @since 1.5.0
*/
public function register(): void {
parent::register();
$this->assets->register_script_asset( self::SCRIPT_HANDLE, [ AMP_Story_Player_Assets::SCRIPT_HANDLE, Tracking::SCRIPT_HANDLE ] );
$this->assets->register_style_asset( self::SCRIPT_HANDLE, [ AMP_Story_Player_Assets::SCRIPT_HANDLE, parent::SCRIPT_HANDLE ] );
wp_localize_script(
self::SCRIPT_HANDLE,
'webStoriesBlockSettings',
$this->get_script_settings()
);
$this->register_block_type();
}
/**
* Renders the block type output for given attributes.
*
* @since 1.5.0
*
* @param array<string, mixed> $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*
* @phpstan-param BlockAttributesWithDefaults $attributes
*/
public function render_block( array $attributes, string $content, WP_Block $block ): string {
if ( false === $this->initialize_block_attributes( $attributes ) ) {
return '';
}
if ( isset( $block->context['postType'], $block->context['postId'] ) && 'web-story' === $block->context['postType'] && ! empty( $block->context['postId'] ) ) {
$attributes = wp_parse_args( $attributes, $this->default_attrs() );
$attributes['class'] = 'wp-block-web-stories-embed';
$story = new Story();
$story->load_from_post( get_post( $block->context['postId'] ) );
return $this->render_story( $story, $attributes );
}
if ( ! empty( $attributes['blockType'] )
&& ( 'latest-stories' === $attributes['blockType'] || 'selected-stories' === $attributes['blockType'] ) ) {
$story_attributes = [
'align' => $attributes['align'],
'view_type' => $attributes['viewType'] ??= '',
'archive_link_label' => $attributes['archiveLinkLabel'] ??= __( 'View all stories', 'web-stories' ),
'circle_size' => $attributes['circleSize'] ??= 96,
'image_alignment' => $attributes['imageAlignment'] ??= 96,
'number_of_columns' => $attributes['numOfColumns'] ??= 2,
];
/**
* Story Attributes.
*
* @phpstan-var StoryAttributes $story_attributes
*/
$story_attributes = array_merge( $story_attributes, $this->get_mapped_field_states() );
return ( new Story_Query( $story_attributes, $this->get_query_args() ) )->render();
}
// Embedding a single story by URL.
$attributes = wp_parse_args( $attributes, $this->default_attrs() );
$attributes['class'] = 'wp-block-web-stories-embed';
return $this->render( $attributes );
}
/**
* Maps fields to the story params.
*
* @since 1.5.0
*
* @return array<string, mixed>
*/
public function get_mapped_field_states(): array {
$controls = [
'show_title' => 'title',
'show_author' => 'author',
'show_excerpt' => 'excerpt',
'show_date' => 'date',
'show_archive_link' => 'archive_link',
'sharp_corners' => 'sharp_corners',
];
$controls_state = [];
foreach ( $controls as $control => $field ) {
$key = 'show_' . $field;
$controls_state[ $control ] = $this->block_attributes['fieldState'][ $key ] ?? false;
}
return $controls_state;
}
/**
* Registers a block type from metadata stored in the `block.json` file.
*
* @since 1.9.0
*/
protected function register_block_type(): void {
$base_path = $this->assets->get_base_path( 'blocks/embed/block.json' );
// Note: does not use 'script' and 'style' args, and instead uses 'render_callback'
// to enqueue these assets only when needed.
register_block_type_from_metadata(
$base_path,
[
'attributes' => [
'blockType' => [
'type' => 'string',
],
'url' => [
'type' => 'string',
],
'title' => [
'type' => 'string',
'default' => __( 'Web Story', 'web-stories' ),
],
'poster' => [
'type' => 'string',
],
'width' => [
'type' => 'number',
'default' => 360,
],
'height' => [
'type' => 'number',
'default' => 600,
],
'align' => [
'type' => 'string',
'default' => 'none',
],
'stories' => [
'type' => 'array',
'default' => [],
],
'viewType' => [
'type' => 'string',
'default' => '',
],
'numOfStories' => [
'type' => 'number',
'default' => 5,
],
'numOfColumns' => [
'type' => 'number',
'default' => 2,
],
'circleSize' => [
'type' => 'number',
'default' => 96,
],
'imageAlignment' => [
'type' => 'number',
'default' => 96,
],
'orderby' => [
'type' => 'string',
'default' => '',
],
'order' => [
'type' => 'string',
'default' => '',
],
'archiveLinkLabel' => [
'type' => 'string',
'default' => __( 'View all stories', 'web-stories' ),
],
'authors' => [
'type' => 'array',
'default' => [],
],
'fieldState' => [
'type' => 'object',
'default' => [],
],
],
'render_callback' => [ $this, 'render_block' ],
'editor_script' => self::SCRIPT_HANDLE,
'editor_style' => self::SCRIPT_HANDLE,
]
);
}
/**
* Returns script settings.
*
* @since 1.5.0
*
* @return array<string, mixed> Script settings.
*/
private function get_script_settings(): array {
$settings = [
'publicPath' => $this->assets->get_base_url( 'assets/js/' ),
'config' => [
'maxNumOfStories' => self::MAX_NUM_OF_STORIES,
'archiveURL' => get_post_type_archive_link( $this->story_post_type->get_slug() ),
'api' => [
'stories' => trailingslashit( $this->story_post_type->get_rest_url() ),
'users' => '/web-stories/v1/users/',
],
'fieldStates' => $this->stories_script_data->fields_states(),
],
];
/**
* Filters settings passed to the web stories block.
*
* @param array<string, mixed> $settings Array of settings passed to web stories block.
*/
return apply_filters( 'web_stories_block_settings', $settings );
}
/**
* Initializes class variable $block_attributes.
*
* @since 1.5.0
*
* @param array $block_attributes Array containing block attributes.
* @return bool Whether or not block attributes have been initialized with given value.
*
* @phpstan-param BlockAttributes $block_attributes
*/
protected function initialize_block_attributes( array $block_attributes = [] ): bool {
if ( ! empty( $block_attributes ) ) {
$this->block_attributes = $block_attributes;
return true;
}
return false;
}
/**
* Returns arguments to be passed to the WP_Query object initialization.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.5.0
*
* @return array<string, mixed> Query arguments.
*/
protected function get_query_args(): array {
$attributes = $this->block_attributes;
$query_args = [
'post_type' => $this->story_post_type->get_slug(),
'post_status' => 'publish',
'suppress_filters' => false,
'no_found_rows' => true,
];
if ( ! empty( $attributes['taxQuery'] ) ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
$query_args['tax_query'] = [];
foreach ( $attributes['taxQuery'] as $taxonomy => $terms ) {
if ( is_taxonomy_viewable( $taxonomy ) && ! empty( $terms ) ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
$query_args['tax_query'][] = [
'taxonomy' => $taxonomy,
'terms' => array_filter( array_map( '\intval', $terms ) ),
'include_children' => false,
];
}
}
}
// if block type is 'selected-tories'.
if ( ! empty( $attributes['blockType'] )
&& 'selected-stories' === $attributes['blockType']
&& ! empty( $attributes['stories'] )
) {
$query_args['post__in'] = $attributes['stories'];
$query_args['orderby'] = 'post__in';
return $query_args;
}
if ( ! empty( $attributes['numOfStories'] ) ) {
$query_args['posts_per_page'] = $attributes['numOfStories'];
}
if ( ! empty( $attributes['order'] ) ) {
$query_args['order'] = strtoupper( $attributes['order'] );
}
if ( ! empty( $attributes['orderby'] ) ) {
$query_args['orderby'] = 'title' === $attributes['orderby'] ? 'post_title' : 'post_date';
}
if ( ! empty( $attributes['authors'] ) ) {
$query_args['author__in'] = $attributes['authors'];
}
return $query_args;
}
}

View File

@@ -0,0 +1,150 @@
<?php
/**
* Class Context
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
/**
* Class Context
*/
class Context {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Context constructor.
*
* @since 1.15.0
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
}
/**
* Determine whether the current response is a single web story.
*
* @since 1.15.0
*
* @return bool Whether it is singular story post (and thus an AMP endpoint).
*/
public function is_web_story(): bool {
return is_singular( $this->story_post_type->get_slug() ) && ! is_embed() && ! post_password_required();
}
/**
* Determine whether the current response being served as AMP.
*
* @since 1.15.0
*
* @return bool Whether it is singular story post (and thus an AMP endpoint).
*/
public function is_amp(): bool {
if ( $this->is_web_story() ) {
return true;
}
// Check for `amp_is_request()` first since `is_amp_endpoint()` is deprecated.
if ( \function_exists( '\amp_is_request' ) ) {
return amp_is_request();
}
if ( \function_exists( '\is_amp_endpoint' ) ) {
return is_amp_endpoint();
}
return false;
}
/**
* Determines whether we're currently on the story editor screen.
*
* @since 1.15.0
*
* @return bool Whether we're currently on the story editor screen.
*/
public function is_story_editor(): bool {
$screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null;
return $screen && $this->story_post_type->get_slug() === $screen->post_type;
}
/**
* Determines whether we're currently on the media upload screen.
*
* @since 1.15.0
*
* @return bool Whether we're currently on the media upload screen
*/
public function is_upload_screen(): bool {
$screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null;
return $screen && 'upload' === $screen->id;
}
/**
* Whether we're currently on a block editor screen.
*
* @since 1.15.0
*/
public function is_block_editor(): bool {
$screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null;
return $screen && $screen->is_block_editor();
}
/**
* Returns the current screen base if available.
*
* @since 1.15.0
*
* @return string|null Current screen base if available.
*/
public function get_screen_base(): ?string {
$screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null;
return $screen->base ?? null;
}
/**
* Returns the current screen post type if available.
*
* @since 1.15.0
*
* @return string|null Current screen post type if available.
*/
public function get_screen_post_type(): ?string {
$screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null;
return $screen->post_type ?? null;
}
}

View File

@@ -0,0 +1,199 @@
<?php
/**
* Class Database_Upgrader
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\Injector;
use Google\Web_Stories\Infrastructure\PluginActivationAware;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Infrastructure\Registerable;
use Google\Web_Stories\Infrastructure\Service;
use Google\Web_Stories\Infrastructure\SiteInitializationAware;
use Google\Web_Stories\Interfaces\Migration;
use WP_Site;
/**
* Class Database_Upgrader
*/
class Database_Upgrader implements Service, Registerable, PluginActivationAware, SiteInitializationAware, PluginUninstallAware {
/**
* The slug of database option.
*/
public const OPTION = 'web_stories_db_version';
/**
* The slug of database option.
*/
public const PREVIOUS_OPTION = 'web_stories_previous_db_version';
/**
* Array of classes to run migration routines.
*/
public const ROUTINES = [
'1.0.0' => Migrations\Update_1::class,
'2.0.0' => Migrations\Replace_Conic_Style_Presets::class,
'2.0.1' => Migrations\Add_Media_Source_Editor::class,
'2.0.2' => Migrations\Remove_Broken_Text_Styles::class,
'2.0.3' => Migrations\Unify_Color_Presets::class,
'2.0.4' => Migrations\Update_Publisher_Logos::class,
'3.0.0' => Migrations\Add_Stories_Caps::class,
'3.0.1' => Migrations\Rewrite_Flush::class,
'3.0.2' => Migrations\Rewrite_Flush::class,
'3.0.4' => Migrations\Add_Poster_Generation_Media_Source::class,
'3.0.5' => Migrations\Remove_Unneeded_Attachment_Meta::class,
'3.0.6' => Migrations\Add_Media_Source_Video_Optimization::class,
'3.0.7' => Migrations\Add_Media_Source_Source_Video::class,
'3.0.8' => Migrations\Rewrite_Flush::class,
'3.0.9' => Migrations\Add_VideoPress_Poster_Generation_Media_Source::class,
'3.0.10' => Migrations\Add_Media_Source_Gif_Conversion::class,
'3.0.11' => Migrations\Add_Media_Source_Source_Image::class,
'3.0.12' => Migrations\Set_Legacy_Analytics_Usage_Flag::class,
'3.0.13' => Migrations\Add_Stories_Caps::class,
'3.0.14' => Migrations\Add_Media_Source_Page_Template::class,
'3.0.15' => Migrations\Add_Media_Source_Recording::class,
'3.0.16' => Migrations\Remove_Incorrect_Tracking_Id::class,
];
/**
* Injector instance.
*
* @var Injector Injector instance.
*/
private Injector $injector;
/**
* Database_Upgrader constructor.
*
* @param Injector $injector Injector instance.
*/
public function __construct( Injector $injector ) {
$this->injector = $injector;
}
/**
* Hooked into admin_init and walks through an array of upgrade methods.
*
* @since 1.0.0
*/
public function register(): void {
add_action( 'admin_init', [ $this, 'run_upgrades' ], 5 );
}
/**
* Act on plugin activation.
*
* @since 1.6.0
*
* @param bool $network_wide Whether the activation was done network-wide.
*/
public function on_plugin_activation( bool $network_wide ): void {
$this->run_upgrades();
}
/**
* Act on site initialization.
*
* @since 1.11.0
*
* @param WP_Site $site The site being initialized.
*/
public function on_site_initialization( WP_Site $site ): void {
$this->run_upgrades();
}
/**
* Run all upgrade routines in order.
*
* @since 1.11.0
*/
public function run_upgrades(): void {
/**
* Current database version.
*
* @var string $version
*/
$version = get_option( self::OPTION, '0.0.0' );
if ( '0.0.0' === $version ) {
$this->finish_up( $version );
return;
}
if ( version_compare( WEBSTORIES_DB_VERSION, $version, '=' ) ) {
return;
}
$routines = self::ROUTINES;
array_walk( $routines, [ $this, 'run_upgrade_routine' ], $version );
$this->finish_up( $version );
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_option( self::PREVIOUS_OPTION );
delete_option( self::OPTION );
}
/**
* Runs the upgrade routine.
*
* @since 1.0.0
*
* @param class-string $class_name The Class to call.
* @param string $version The new version.
* @param string $current_version The current set version.
*/
protected function run_upgrade_routine( string $class_name, string $version, string $current_version ): void {
if ( version_compare( $current_version, $version, '<' ) ) {
/**
* Instance of a migration class.
*
* @var Migration $routine
*/
$routine = $this->injector->make( $class_name );
$routine->migrate();
}
}
/**
* Runs the needed cleanup after an update, setting the DB version to latest version, flushing caches etc.
*
* @since 1.0.0
*
* @param string $previous_version The previous version.
*/
protected function finish_up( string $previous_version ): void {
update_option( self::PREVIOUS_OPTION, $previous_version );
update_option( self::OPTION, WEBSTORIES_DB_VERSION );
}
}

View File

@@ -0,0 +1,72 @@
<?php
/**
* Class Decoder
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
/**
* Decoder class.
*
* @since 1.1.0
*/
class Decoder {
/**
* Determines whether encoding and decoding of story markup is supported.
*
* @since 1.1.0
*
* @return bool Whether decoding is supported.
*/
public function supports_decoding(): bool {
/**
* Filter whether the encoding requests.
*
* @since 1.2.0
*
* @param bool $enable_decoding Enable disable encoding.
*/
return apply_filters( 'web_stories_enable_decoding', true );
}
/**
* Decodes string if encoded.
*
* @since 1.1.0
*
* @param string $content String to decode.
* @return string Decoded string.
*/
public function base64_decode( string $content ): string {
if ( str_starts_with( $content, '__WEB_STORIES_ENCODED__' ) ) {
$content = str_replace( '__WEB_STORIES_ENCODED__', '', $content );
return urldecode( base64_decode( $content ) );
}
return $content;
}
}

View File

@@ -0,0 +1,179 @@
<?php
/**
* Demo_Content class.
*
* Used for getting demo content.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
/**
* Demo_Content class.
*/
class Demo_Content {
/**
* Returns the title for the demo story.
*/
public function get_title(): string {
return __( 'Tips to make the most of Web Stories', 'web-stories' );
}
/**
* Returns the content for the demo story.
*/
public function get_content(): string {
$content = $this->load_demo_content_from_file();
$content = $this->localize_texts( $content );
$content = $this->update_assets_urls( $content );
// Quick sanity check to see if the JSON is still valid.
if ( null === json_decode( $content, true ) ) {
return '';
}
return $content;
}
/**
* Updates URLs to media assets in demo content.
*
* @param string $content Original content.
* @return string Modified content.
*/
private function update_assets_urls( string $content ): string {
return str_replace(
'https://replaceme.com/',
trailingslashit( WEBSTORIES_CDN_URL ),
$content
);
}
/**
* Localizes demo content.
*
* @param string $content Original content.
* @return string Localized text.
*/
private function localize_texts( string $content ): string {
$replacements = [
// Page 1.
'L10N_PLACEHOLDER_1_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Tips to make the most of the Web Stories Editor', 'demo content', 'web-stories' ),
// Page 2.
'L10N_PLACEHOLDER_2_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Set a page background', 'demo content', 'web-stories' ),
'L10N_PLACEHOLDER_2_2' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Drag your image or video to the edge of the page to set as page background.', 'demo content', 'web-stories' ),
// Page 3.
'L10N_PLACEHOLDER_3_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Media Edit Mode', 'demo content', 'web-stories' ),
'L10N_PLACEHOLDER_3_2' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Double-click the image/video to resize, re-center or crop. Note: media set as page background cannot be cropped.', 'demo content', 'web-stories' ),
// Page 4.
'L10N_PLACEHOLDER_4_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Background Overlay', 'demo content', 'web-stories' ),
'L10N_PLACEHOLDER_4_2' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Once you\'ve set a page bg, add a solid, linear or radial gradient overlay to increase text contrast or add visual styling.', 'demo content', 'web-stories' ),
// Page 5.
'L10N_PLACEHOLDER_5_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Safe Zone', 'demo content', 'web-stories' ),
'L10N_PLACEHOLDER_5_2' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Add your designs to the page, keeping crucial elements inside the safe zone to ensure they are visible across most devices.', 'demo content', 'web-stories' ),
// Page 6.
'L10N_PLACEHOLDER_6_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Story System Layer', 'demo content', 'web-stories' ),
'L10N_PLACEHOLDER_6_2' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'The system layer is docked at the top. Preview your story to ensure system layer icons are not blocking crucial elements.', 'demo content', 'web-stories' ),
// Page 7.
'L10N_PLACEHOLDER_7_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Shapes and Masks', 'demo content', 'web-stories' ),
'L10N_PLACEHOLDER_7_2' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Our shapes are quite basic for now but they act as masks. Drag an image or video into the mask.', 'demo content', 'web-stories' ),
// Page 8.
'L10N_PLACEHOLDER_8_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Embed Visual Stories', 'demo content', 'web-stories' ),
'L10N_PLACEHOLDER_8_2' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Embed stories into your blog post. Open the block menu & select the Web Stories block. Insert the story link to embed your story. That\'s it!', 'demo content', 'web-stories' ),
// Page 9.
'L10N_PLACEHOLDER_9_1' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Read about best practices for creating successful Web Stories', 'demo content', 'web-stories' ),
'L10N_PLACEHOLDER_9_2' => /* translators: demo content used in the "Get Started" story */
esc_url( _x( 'https://amp.dev/documentation/guides-and-tutorials/start/create_successful_stories/', 'demo content', 'web-stories' ) ),
'L10N_PLACEHOLDER_9_3' => /* translators: demo content used in the "Get Started" story */
esc_html_x( 'Best Practices', 'demo content', 'web-stories' ),
];
foreach ( $replacements as $search => $replacement ) {
$content = str_replace( $search, $replacement, $content );
}
return $content;
}
/**
* Loads demo content from JSON file.
*/
private function load_demo_content_from_file(): string {
$file = WEBSTORIES_PLUGIN_DIR_PATH . 'includes/data/stories/demo.json';
if ( ! is_readable( $file ) ) {
return '';
}
$content = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown
if ( ! $content ) {
return '';
}
return $content;
}
}

View File

@@ -0,0 +1,521 @@
<?php
/**
* Class Discovery.
*
* Responsible for improved discovery of stories on the web.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Model\Story;
use Google\Web_Stories\Shopping\Product;
use WP_Post;
/**
* Discovery class.
*/
class Discovery extends Service_Base implements HasRequirements {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Constructor.
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type', 'product_meta' ];
}
/**
* Initialize discovery functionality.
*
* @since 1.0.0
*/
public function register(): void {
add_action( 'web_stories_story_head', [ $this, 'print_document_title' ] );
add_action( 'web_stories_story_head', [ $this, 'print_metadata' ] );
add_action( 'web_stories_story_head', [ $this, 'print_schemaorg_metadata' ] );
add_action( 'web_stories_story_head', [ $this, 'print_open_graph_metadata' ] );
add_action( 'web_stories_story_head', [ $this, 'print_twitter_metadata' ] );
add_action( 'web_stories_story_head', [ $this, 'print_feed_link' ], 4 );
add_action( 'wp_head', [ $this, 'print_feed_link' ], 4 );
// @todo Check if there's something to skip in the new version.
add_action( 'web_stories_story_head', 'rest_output_link_wp_head', 10, 0 );
add_action( 'web_stories_story_head', 'wp_resource_hints', 2 );
add_action( 'web_stories_story_head', 'feed_links', 2 );
add_action( 'web_stories_story_head', 'feed_links_extra', 3 );
add_action( 'web_stories_story_head', 'rsd_link' );
add_action( 'web_stories_story_head', 'adjacent_posts_rel_link_wp_head', 10, 0 );
add_action( 'web_stories_story_head', 'wp_generator' );
add_action( 'web_stories_story_head', 'rel_canonical' );
add_action( 'web_stories_story_head', 'wp_shortlink_wp_head', 10, 0 );
add_action( 'web_stories_story_head', 'wp_site_icon', 99 );
add_action( 'web_stories_story_head', 'wp_oembed_add_discovery_links' );
add_action( 'web_stories_story_head', 'wp_robots', 1 );
}
/**
* Prints document title for stories.
*
* Adds the title regardless of theme support.
*
* AMP sanitization will ensure there is always just exactly one title tag present.
*
* @since 1.25.0
*
* @link https://github.com/GoogleForCreators/web-stories-wp/issues/12139
* @link https://github.com/GoogleForCreators/web-stories-wp/issues/12487
* @link https://github.com/GoogleForCreators/web-stories-wp/issues/12655
*/
public function print_document_title(): void {
/**
* Filters whether to print the document title.
*
* @since 1.25.0
*
* @param bool $enable_open_graph Whether to print the document title. Default to true.
*/
$enable_metadata = apply_filters( 'web_stories_enable_document_title', true );
if ( ! $enable_metadata ) {
return;
}
?>
<title><?php echo esc_html( wp_get_document_title() ); ?></title>
<?php
}
/**
* Prints the meta description on the single story template.
*
* Adds the meta mescription regardless of theme support.
*
* AMP sanitization will ensure there is always just exactly one meta description present.
*
* @since 1.0.0
*
* @see _wp_render_title_tag().
*/
public function print_metadata(): void {
/**
* Filters whether to print the meta description.
*
* @since 1.2.0
*
* @param bool $enable_open_graph Whether to print the meta description. Default to true.
*/
$enable_metadata = apply_filters( 'web_stories_enable_metadata', true );
if ( ! $enable_metadata ) {
return;
}
?>
<meta name="description" content="<?php echo esc_attr( wp_strip_all_tags( get_the_excerpt() ) ); ?>" />
<?php
}
/**
* Prints the schema.org metadata on the single story template.
*
* @since 1.0.0
*/
public function print_schemaorg_metadata(): void {
/**
* Filters filter to enable / disable schemaorg metadata.
*
* @since 1.2.0
*
* @param bool $enable_schemaorg_metadata Enable / disable schemaorg metadata. Default to true.
*/
$enable_schemaorg_metadata = apply_filters( 'web_stories_enable_schemaorg_metadata', true );
if ( ! $enable_schemaorg_metadata ) {
return;
}
$metadata = $this->get_schemaorg_metadata();
?>
<script type="application/ld+json"><?php echo wp_json_encode( $metadata, JSON_UNESCAPED_UNICODE ); ?></script>
<?php
}
/**
* Prints Open Graph metadata.
*
* @since 1.0.0
*/
public function print_open_graph_metadata(): void {
/**
* Filters filter to enable / disable open graph metadata.
*
* @since 1.2.0
*
* @param bool $enable_open_graph_metadata Enable / disable open graph metadata. Default to true.
*/
$enable_open_graph_metadata = apply_filters( 'web_stories_enable_open_graph_metadata', true );
if ( ! $enable_open_graph_metadata ) {
return;
}
$metadata = $this->get_open_graph_metadata();
foreach ( $metadata as $name => $value ) {
printf( '<meta property="%s" content="%s" />', esc_attr( $name ), esc_attr( (string) $value ) );
}
}
/**
* Prints Twitter card metadata.
*
* @since 1.0.0
*/
public function print_twitter_metadata(): void {
/**
* Filters filter to enable / disable twitter metadata.
*
* @since 1.2.0
*
* @param bool $enable_twitter_metadata Enable / disable twitter metadata. Default to true.
*/
$enable_twitter_metadata = apply_filters( 'web_stories_enable_twitter_metadata', true );
if ( ! $enable_twitter_metadata ) {
return;
}
$metadata = $this->get_twitter_metadata();
foreach ( $metadata as $name => $value ) {
printf( '<meta name="%s" content="%s" />', esc_attr( $name ), esc_attr( $value ) );
}
}
/**
* Add RSS feed link for stories, if theme supports automatic-feed-links.
*
* @since 1.0.0
*/
public function print_feed_link(): void {
$enable_print_feed_link = current_theme_supports( 'automatic-feed-links' ) && ! is_post_type_archive( $this->story_post_type->get_slug() );
/**
* Filters filter to enable / disable printing feed links.
*
* @since 1.29.0
*
* @param bool $enable_print_feed_link Enable / disable printing feed links. Default to if automatic-feed-links is enabled.
*/
$enable_print_feed_link = apply_filters( 'web_stories_enable_print_feed_link', $enable_print_feed_link );
if ( ! $enable_print_feed_link ) {
return;
}
$name = $this->story_post_type->get_label( 'name' );
if ( ! $name ) {
return;
}
$feed = get_post_type_archive_feed_link( $this->story_post_type->get_slug() );
if ( ! $feed ) {
return;
}
/* translators: Separator between blog name and feed type in feed links. */
$separator = _x( '&raquo;', 'feed link', 'web-stories' );
/* translators: 1: Blog name, 2: Separator (raquo), 3: Post type name. */
$post_type_title = esc_html__( '%1$s %2$s %3$s Feed', 'web-stories' );
$title = sprintf( $post_type_title, get_bloginfo( 'name' ), $separator, $name );
printf(
'<link rel="alternate" type="%s" title="%s" href="%s">',
esc_attr( feed_content_type() ),
esc_attr( $title ),
esc_url( $feed )
);
}
/**
* Get schema.org metadata for the current query.
*
* @since 1.0.0
*
* @see https://developers.google.com/search/docs/guides/enable-web-stories
*
* @return array<string,mixed> $metadata All schema.org metadata for the post.
*/
protected function get_schemaorg_metadata(): array {
/**
* We're expecting a post object.
*
* @var WP_Post|null $post
*/
$post = get_queried_object();
$story = new Story();
$story->load_from_post( $post );
$metadata = [
'@context' => 'http://schema.org',
'publisher' => [
'@type' => 'Organization',
'name' => $story->get_publisher_name(),
],
];
if ( $post instanceof WP_Post ) {
$url = $story->get_publisher_logo_url();
$size = $story->get_publisher_logo_size();
if ( ! empty( $url ) && ! empty( $size ) ) {
$metadata['publisher']['logo'] = [
'@type' => 'ImageObject',
'url' => $url,
'width' => $size[0],
'height' => $size[1],
];
}
$poster = $story->get_poster_portrait();
$poster_size = $story->get_poster_portrait_size();
if ( $poster && $poster_size ) {
$metadata['image'] = [
'@type' => 'ImageObject',
'url' => $poster,
'width' => $poster_size[0],
'height' => $poster_size[1],
];
}
$metadata = array_merge(
$metadata,
[
'@type' => 'Article',
'mainEntityOfPage' => $story->get_url(),
'headline' => $story->get_title(),
'datePublished' => mysql2date( 'c', $post->post_date_gmt, false ),
'dateModified' => mysql2date( 'c', $post->post_modified_gmt, false ),
]
);
$post_author = get_userdata( (int) $post->post_author );
if ( $post_author ) {
$metadata['author'] = [
'@type' => 'Person',
'name' => html_entity_decode( $post_author->display_name, ENT_QUOTES, get_bloginfo( 'charset' ) ),
];
}
/**
* List of products.
*
* @phpstan-var Product[] $products
*/
$products = $story->get_products();
$product_metadata = $this->get_product_data( $products );
if ( $product_metadata ) {
$metadata = array_merge( $product_metadata, $metadata );
}
}
/**
* Filters the schema.org metadata for a given story.
*
* @since 1.0.0
*
* @param array $metadata The structured data.
* @param WP_Post|null $post The current post object if available.
*/
return apply_filters( 'web_stories_story_schema_metadata', $metadata, $post );
}
/**
* Get product schema data.
*
* @since 1.22.0
*
* @param Product[] $products Array of products.
* @return array<string, array<string, array<int, array<string, mixed>>|string>>
*
* @phpstan-param Product[] $products
*/
protected function get_product_data( array $products ): array {
if ( ! $products ) {
return [];
}
$product_data = [];
foreach ( $products as $product ) {
$data = [
'@type' => 'Product',
'brand' => $product->get_brand(),
'productID' => $product->get_id(),
'url' => $product->get_url(),
'name' => $product->get_title(),
'description' => $product->get_details(),
'offers' => [
[
'@type' => 'Offer',
'price' => $product->get_price(),
'priceCurrency' => $product->get_price_currency(),
],
],
];
if ( $product->get_images() ) {
$data['image'] = $product->get_images()[0]['url'];
}
$aggregate_rating = $product->get_aggregate_rating();
if ( ! empty( $aggregate_rating['review_count'] ) ) {
$data['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => $aggregate_rating['rating_value'] ??= 0,
'reviewCount' => $aggregate_rating['review_count'],
'url' => $aggregate_rating['review_url'] ??= '',
];
}
$product_data[] = $data;
}
return [
'mainEntity' => [
'@type' => 'ItemList',
'numberOfItems' => (string) \count( $products ),
'itemListElement' => $product_data,
],
];
}
/**
* Get Open Graph metadata.
*
* @since 1.3.0
*
* @return array<string, string|int>
*/
protected function get_open_graph_metadata(): array {
$metadata = [
'og:locale' => get_bloginfo( 'language' ),
'og:site_name' => get_bloginfo( 'name' ),
];
/**
* We're expecting a post object.
*
* @var WP_Post|null $post
*/
$post = get_queried_object();
if ( $post instanceof WP_Post ) {
$story = new Story();
$story->load_from_post( $post );
$metadata['og:type'] = 'article';
$metadata['og:title'] = $story->get_title();
$metadata['og:url'] = $story->get_url();
$metadata['og:description'] = wp_strip_all_tags( get_the_excerpt( $post ) );
$metadata['article:published_time'] = (string) get_the_date( 'c', $post );
$metadata['article:modified_time'] = (string) get_the_modified_date( 'c', $post );
$poster_url = $story->get_poster_portrait();
$poster_sizes = $story->get_poster_portrait_size();
if ( $poster_url && $poster_sizes ) {
$metadata['og:image'] = esc_url( $poster_url );
$metadata['og:image:width'] = $poster_sizes[0];
$metadata['og:image:height'] = $poster_sizes[1];
}
}
/**
* Filters the open graph metadata for a given story.
*
* @since 1.3.0
*
* @param array $metadata The structured data.
* @param WP_Post|null $post The current post object if available.
*/
return apply_filters( 'web_stories_story_open_graph_metadata', $metadata, $post );
}
/**
* Get Twitter card metadata.
*
* @since 1.3.0
*
* @return array<string, string> Twitter card metadata.
*/
protected function get_twitter_metadata(): array {
$metadata = [
'twitter:card' => 'summary_large_image',
];
/**
* We're expecting a post object.
*
* @var WP_Post|null $post
*/
$post = get_queried_object();
if ( $post instanceof WP_Post ) {
$story = new Story();
$story->load_from_post( $post );
$poster = $story->get_poster_portrait();
if ( $poster ) {
$metadata['twitter:image'] = esc_url( $poster );
$metadata['twitter:image:alt'] = $story->get_title();
}
}
/**
* Filters the twitter metadata for a given story.
*
* @since 1.3.0
*
* @param array $metadata The structured data.
* @param WP_Post|null $post The current post object if available.
*/
return apply_filters( 'web_stories_story_twitter_metadata', $metadata, $post );
}
}

View File

@@ -0,0 +1,219 @@
<?php
/**
* Class Embed_Block.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Model\Story;
use Google\Web_Stories\Renderer\Story\Embed;
use Google\Web_Stories\Renderer\Story\Image;
use Google\Web_Stories\Renderer\Story\Singleton;
/**
* Embed block class.
*/
abstract class Embed_Base extends Service_Base {
/**
* Script handle for frontend assets.
*/
public const SCRIPT_HANDLE = 'web-stories-embed';
/**
* Assets instance.
*
* @var Assets Assets instance.
*/
protected Assets $assets;
/**
* Context instance.
*
* @var Context Context instance.
*/
protected Context $context;
/**
* Embed Base constructor.
*
* @since 1.8.0
*
* @param Assets $assets Assets instance.
* @param Context $context Context instance.
*/
public function __construct( Assets $assets, Context $context ) {
$this->assets = $assets;
$this->context = $context;
}
/**
* Initializes the Web Stories embed block.
*
* @since 1.1.0
*/
public function register(): void {
if ( wp_style_is( self::SCRIPT_HANDLE, 'registered' ) ) {
return;
}
$this->assets->register_style_asset( self::SCRIPT_HANDLE );
if ( \defined( 'AMPFORWP_VERSION' ) ) {
add_action( 'amp_post_template_css', [ $this, 'add_amp_post_template_css' ] );
}
add_filter( 'wp_kses_allowed_html', [ $this, 'filter_kses_allowed_html' ] );
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.6.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 11;
}
/**
* Prints required inline CSS when using the AMP for WP plugin.
*
* @since 1.13.0
*/
public function add_amp_post_template_css(): void {
$path = $this->assets->get_base_path( sprintf( 'assets/css/%s%s.css', self::SCRIPT_HANDLE, is_rtl() ? '-rtl' : '' ) );
if ( is_readable( $path ) ) {
$css = file_get_contents( $path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown
echo $css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/**
* Filter the allowed tags for KSES to allow for amp-story children.
*
* @since 1.0.0
*
* @param array<string, array<string,bool>>|mixed $allowed_tags Allowed tags.
* @return array<string, array<string,bool>>|mixed Allowed tags.
*
* @template T
*
* @phpstan-return ($allowed_tags is array<T> ? array<T> : mixed)
*/
public function filter_kses_allowed_html( $allowed_tags ) {
if ( ! \is_array( $allowed_tags ) ) {
return $allowed_tags;
}
$story_player_components = [
'amp-story-player' => [],
];
$allowed_tags = array_merge( $allowed_tags, $story_player_components );
return $allowed_tags;
}
/**
* Renders a story with given attributes.
*
* @since 1.30.0
*
* @param Story $story Story instance.
* @param array<string, string|int> $attributes Embed render attributes.
* @return string Rendered embed output.
*/
public function render_story( Story $story, array $attributes ): string {
if ( is_feed() ) {
$renderer = new Image( $story );
} elseif ( ! empty( $attributes['previewOnly'] ) ) {
$renderer = new Singleton( $story, $this->assets );
} else {
$renderer = new Embed( $story, $this->assets, $this->context );
}
return $renderer->render( $attributes );
}
/**
* Renders an embed with given attributes.
*
* @since 1.1.0
*
* @param array<string, string|int> $attributes Embed render attributes.
* @return string Rendered embed output.
*/
public function render( array $attributes ): string {
// The only mandatory attribute.
if ( empty( $attributes['url'] ) && empty( $attributes['previewOnly'] ) ) {
return '';
}
if ( empty( $attributes['title'] ) ) {
$attributes['title'] = __( 'Web Story', 'web-stories' );
}
$data = [
'title' => $attributes['title'],
'url' => $attributes['url'],
'poster_portrait' => $attributes['poster'],
];
$story = new Story( $data );
return $this->render_story( $story, $attributes );
}
/**
* Return an array of default attributes.
*
* @since 1.1.0
*
* @return array<string, string|int> Default attributes.
*/
protected function default_attrs(): array {
$attrs = [
'align' => 'none',
'height' => 600,
'poster' => '',
'url' => '',
'title' => '',
'width' => 360,
'previewOnly' => false,
];
/**
* Filters settings passed to the web stories embed.
*
* @since 1.1.0
*
* @param array $attrs Array of settings passed to web stories embed.
*/
return apply_filters( 'web_stories_embed_default_attributes', $attrs );
}
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* Exception FailedToMakeInstance.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Exception;
use Google\Web_Stories\Infrastructure\Injector\InjectionChain;
use RuntimeException;
/**
* Exception thrown when the injector couldn't instantiate a given class or
* interface.
*
* @internal
*
* @since 1.6.0
*/
final class FailedToMakeInstance extends RuntimeException implements WebStoriesException {
// These constants are public so you can use them to find out what exactly
// happened when you catch a "FailedToMakeInstance" exception.
public const CIRCULAR_REFERENCE = 100;
public const UNRESOLVED_INTERFACE = 200;
public const UNREFLECTABLE_CLASS = 300;
public const UNRESOLVED_ARGUMENT = 400;
public const UNINSTANTIATED_SHARED_INSTANCE = 500;
public const INVALID_DELEGATE = 600;
/**
* Create a new instance of the exception for an interface or class that
* created a circular reference.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class name that
* generated the circular
* reference.
* @param InjectionChain $injection_chain Injection chain that led to the
* circular reference.
*/
public static function for_circular_reference(
string $interface_or_class,
InjectionChain $injection_chain
): self {
$message = \sprintf(
'Circular reference detected while trying to resolve the interface or class "%s".',
$interface_or_class
);
$message .= "\nInjection chain:\n";
foreach ( $injection_chain->get_chain() as $link ) {
$message .= "{$link}\n";
}
return new self( $message, self::CIRCULAR_REFERENCE );
}
/**
* Create a new instance of the exception for an interface that could not
* be resolved to an instantiable class.
*
* @since 1.6.0
*
* @param string $interface_name Interface that was left unresolved.
*/
public static function for_unresolved_interface( string $interface_name ): self {
$message = \sprintf(
'Could not resolve the interface "%s" to an instantiable class, probably forgot to bind an implementation.',
$interface_name
);
return new self( $message, self::UNRESOLVED_INTERFACE );
}
/**
* Create a new instance of the exception for an interface or class that
* could not be reflected upon.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class that could not be
* reflected upon.
*/
public static function for_unreflectable_class( string $interface_or_class ): self {
$message = \sprintf(
'Could not reflect on the interface or class "%s", probably not a valid FQCN.',
$interface_or_class
);
return new self( $message, self::UNREFLECTABLE_CLASS );
}
/**
* Create a new instance of the exception for an argument that could not be
* resolved.
*
* @since 1.6.0
*
* @param string $argument_name Name of the argument that could not be
* resolved.
* @param string $class_name Class that had the argument in its
* constructor.
*/
public static function for_unresolved_argument( string $argument_name, string $class_name ): self {
$message = \sprintf(
'Could not resolve the argument "%s" while trying to instantiate the class "%s".',
$argument_name,
$class_name
);
return new self( $message, self::UNRESOLVED_ARGUMENT );
}
/**
* Create a new instance of the exception for a class that was meant to be
* reused but was not yet instantiated.
*
* @since 1.6.0
*
* @param string $class_name Class that was not yet instantiated.
*/
public static function for_uninstantiated_shared_instance( string $class_name ): self {
$message = \sprintf(
'Could not retrieve the shared instance for "%s" as it was not instantiated yet.',
$class_name
);
return new self( $message, self::UNINSTANTIATED_SHARED_INSTANCE );
}
/**
* Create a new instance of the exception for a delegate that was requested
* for a class that doesn't have one.
*
* @since 1.6.0
*
* @param string $class_name Class for which there is no delegate.
*/
public static function for_invalid_delegate( string $class_name ): self {
$message = \sprintf(
'Could not retrieve a delegate for "%s", none was defined.',
$class_name
);
return new self( $message, self::INVALID_DELEGATE );
}
}

View File

@@ -0,0 +1,102 @@
<?php
/**
* Exception InvalidEventProperties.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Exception;
use InvalidArgumentException;
/**
* Exception thrown when an invalid properties are added to an Event.
*
* @internal
*
* @since 1.6.0
*/
final class InvalidEventProperties extends InvalidArgumentException implements WebStoriesException {
/**
* Create a new instance of the exception for a properties value that has
* the wrong type.
*
* @since 1.6.0
*
* @param mixed $properties Properties value that has the wrong type.
*/
public static function from_invalid_type( $properties ): self {
$type = \is_object( $properties )
? \get_class( $properties )
: \gettype( $properties );
$message = sprintf(
'The properties argument for adding properties to an event needs to be an array, but is of type %s',
$type
);
return new self( $message );
}
/**
* Create a new instance of the exception for a properties value that has
* the wrong key type for one or more of its elements.
*
* @since 1.6.0
*
* @param mixed $property Property element that has the wrong type.
*/
public static function from_invalid_element_key_type( $property ): self {
$type = \is_object( $property )
? \get_class( $property )
: \gettype( $property );
$message = sprintf(
'Each property element key for adding properties to an event needs to of type string, but found an element key of type %s',
$type
);
return new self( $message );
}
/**
* Create a new instance of the exception for a properties value that has
* the wrong value type for one or more of its elements.
*
* @param mixed $property Property element that has the wrong type.
*/
public static function from_invalid_element_value_type( $property ): self {
$type = \is_object( $property )
? \get_class( $property )
: \gettype( $property );
$message = sprintf(
'Each property element value for adding properties to an event needs to be a scalar value, but found an element value of type %s',
$type
);
return new self( $message );
}
}

View File

@@ -0,0 +1,77 @@
<?php
/**
* Exception InvalidService.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Exception;
use InvalidArgumentException;
/**
* Exception thrown when an invalid service was requested.
*
* @internal
*
* @since 1.6.0
*/
final class InvalidService extends InvalidArgumentException implements WebStoriesException {
/**
* Create a new instance of the exception for a service class name that is
* not recognized.
*
* @since 1.6.0
*
* @param string|object $service Class name of the service that was not
* recognized.
*/
public static function from_service( $service ): self {
$message = \sprintf(
'The service "%s" is not recognized and cannot be registered.',
\is_object( $service )
? \get_class( $service )
: (string) $service
);
return new self( $message );
}
/**
* Create a new instance of the exception for a service identifier that is
* not recognized.
*
* @param string $service_id Identifier of the service that is not being
* recognized.
*/
public static function from_service_id( string $service_id ): self {
$message = \sprintf(
'The service ID "%s" is not recognized and cannot be retrieved.',
$service_id
);
return new self( $message );
}
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* Exception SanitizationException.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Exception;
use RuntimeException;
/**
* Exception thrown when AMP sanitization errors.
*
* @internal
*
* @since 1.10.0
*/
final class SanitizationException extends RuntimeException implements WebStoriesException {
/**
* Create a new instance of the exception for a document that cannot be parsed.
*
* @since 1.10.0
*/
public static function from_document_parse_error(): self {
return new self( 'The markup could not be parsed into a DOMDocument.' );
}
}

View File

@@ -0,0 +1,44 @@
<?php
/**
* Interface WebStoriesException.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Exception;
/**
* This is a "marker interface" to mark all the exceptions that come with this
* plugin with this one interface.
*
* This allows you to not only catch individual exceptions, but also catch "all
* exceptions from the Web_Stories plugin".
*
* @internal
*
* @since 1.6.0
*/
interface WebStoriesException {
}

View File

@@ -0,0 +1,382 @@
<?php
/**
* Class Experiments
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\HasRequirements;
/**
* Experiments class.
*
* Allows turning flags on/off via the admin UI.
*
* @phpstan-type Experiment array{
* name: string,
* label: string,
* description: string,
* group: string,
* default?: bool
* }
*/
class Experiments extends Service_Base implements HasRequirements {
/**
* Settings page name.
*/
public const PAGE_NAME = 'web-stories-experiments';
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Experiments constructor.
*
* @since 1.12.0
*
* @param Settings $settings Settings instance.
* @return void
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Initializes experiments
*/
public function register(): void {
if ( WEBSTORIES_DEV_MODE ) {
add_action( 'admin_menu', [ $this, 'add_menu_page' ], 25 );
add_action( 'admin_init', [ $this, 'initialize_settings' ] );
}
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because settings needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'settings' ];
}
/**
* Registers the experiments admin menu page.
*
* @since 1.0.0
*/
public function add_menu_page(): void {
add_submenu_page(
'edit.php?post_type=' . Story_Post_Type::POST_TYPE_SLUG,
__( 'Experiments', 'web-stories' ),
__( 'Experiments', 'web-stories' ),
'manage_options',
'web-stories-experiments',
[ $this, 'render' ],
25
);
}
/**
* Renders the experiments page.
*
* @codeCoverageIgnore
*/
public function render(): void {
require_once WEBSTORIES_PLUGIN_DIR_PATH . 'includes/templates/admin/experiments.php';
}
/**
* Initializes the experiments settings page.
*
* @since 1.0.0
*/
public function initialize_settings(): void {
add_settings_section(
'web_stories_experiments_section',
// The empty string ensures the render function won't output a h2.
'',
[ $this, 'display_experiment_section' ],
self::PAGE_NAME
);
foreach ( $this->get_experiment_groups() as $group => $label ) {
add_settings_section(
$group,
$label,
'__return_empty_string',
self::PAGE_NAME
);
}
$experiments = $this->get_experiments();
foreach ( $experiments as $experiment ) {
add_settings_field(
$experiment['name'],
$experiment['label'],
[ $this, 'display_experiment_field' ],
self::PAGE_NAME,
$experiment['group'],
[
'label' => $experiment['description'],
'id' => $experiment['name'],
'default' => \array_key_exists( 'default', $experiment ) && $experiment['default'],
]
);
}
}
/**
* Display a checkbox field for a single experiment.
*
* @since 1.0.0
*
* @param array $args {
* Array of arguments for displaying a single field.
*
* @type string $id Experiment ID.
* @type string $label Experiment label.
* @type bool $default Whether the experiment is enabled by default.
* }
*
* @phpstan-param array{id: string, label: string, default: bool} $args
*/
public function display_experiment_field( array $args ): void {
$is_enabled_by_default = ! empty( $args['default'] );
$checked = $is_enabled_by_default || $this->is_experiment_enabled( $args['id'] );
$disabled = $is_enabled_by_default ? 'disabled' : '';
?>
<label for="<?php echo esc_attr( $args['id'] ); ?>">
<input
type="checkbox"
name="<?php echo esc_attr( sprintf( '%1$s[%2$s]', $this->settings::SETTING_NAME_EXPERIMENTS, $args['id'] ) ); ?>"
id="<?php echo esc_attr( $args['id'] ); ?>"
value="1"
<?php echo esc_attr( $disabled ); ?>
<?php checked( $checked ); ?>
/>
<?php echo esc_html( $args['label'] ); ?>
</label>
<?php
}
/**
* Display the experiments section.
*
* @codeCoverageIgnore
*/
public function display_experiment_section(): void {
?>
<p>
<?php
esc_html_e( "The Web Stories editor includes experimental features that are useable while they're in development. Select the ones you'd like to enable. These features are likely to change, so avoid using them in production.", 'web-stories' );
?>
</p>
<?php
}
/**
* Returns all available experiment groups.
*
* @since 1.0.0
*
* @return array<string,string> List of experiment groups
*/
public function get_experiment_groups(): array {
return [
'general' => __( 'General', 'web-stories' ),
'dashboard' => __( 'Dashboard', 'web-stories' ),
'editor' => __( 'Editor', 'web-stories' ),
];
}
/**
* Returns a list of all experiments.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.0.0
*
* @return array List of experiments by group.
*
* @phpstan-return Experiment[]
*/
public function get_experiments(): array {
return [
/**
* Author: @brittanyirl
* Issue: 2381
* Creation date: 2020-06-11
*/
[
'name' => 'enableInProgressTemplateActions',
'label' => __( 'Template actions', 'web-stories' ),
'description' => __( 'Enable in-progress template actions', 'web-stories' ),
'group' => 'dashboard',
],
/**
* Author: @spacedmonkey
* Issue: #798
* Creation date: 2020-11-02
*/
[
'name' => 'enableSVG',
'label' => __( 'SVG upload', 'web-stories' ),
'description' => __( 'Enable SVG upload', 'web-stories' ),
'group' => 'general',
],
/**
* Author: @timarney
* Issue: #12093
* Creation date: 2022-08-18
*/
[
'name' => 'offScreenVideoCropping',
'label' => __( 'Crop off-screen video parts', 'web-stories' ),
'description' => __( 'Enable support for cropping cut off-screen parts of videos', 'web-stories' ),
'group' => 'editor',
],
/**
* Author: @spacedmonkey
* Issue: #12211
* Creation date: 2022-09-07
*/
[
'name' => 'videoVolume',
'label' => __( 'Video Volume', 'web-stories' ),
'description' => __( 'Enable setting video volume', 'web-stories' ),
'group' => 'editor',
],
/**
* Author: @timarney
* Issue: #12164
* Creation date: 2022-09-19
*/
[
'name' => 'segmentVideo',
'label' => __( 'Segment video', 'web-stories' ),
'description' => __( 'Enable support for segmenting video files', 'web-stories' ),
'group' => 'editor',
],
];
}
/**
* Returns the experiment statuses for a given group.
*
* @since 1.0.0
*
* @param string $group Experiments group name.
* @return array<string,bool> Experiment statuses with name as key and status as value.
*/
public function get_experiment_statuses( string $group ): array {
/**
* List of experiments.
*
* @phpstan-var Experiment[]
*/
$experiments = wp_list_filter( $this->get_experiments(), [ 'group' => $group ] );
if ( empty( $experiments ) ) {
return [];
}
$result = [];
foreach ( $experiments as $experiment ) {
$result[ $experiment['name'] ] = $this->is_experiment_enabled( $experiment['name'] );
}
return $result;
}
/**
* Checks whether an experiment is enabled.
*
* @since 1.0.0
*
* @param string $name Experiment name.
* @return bool Whether the experiment is enabled.
*/
public function is_experiment_enabled( string $name ): bool {
$experiment = $this->get_experiment( $name );
if ( ! $experiment ) {
return false;
}
if ( \array_key_exists( 'default', $experiment ) ) {
return (bool) $experiment['default'];
}
/**
* List of enabled experiments.
*
* @var array<string, array> $experiments
* @phpstan-var Experiment[]
*/
$experiments = $this->settings->get_setting( $this->settings::SETTING_NAME_EXPERIMENTS, [] );
return ! empty( $experiments[ $name ] );
}
/**
* Returns the names of all enabled experiments.
*
* @since 1.4.0
*
* @return string[] List of all enabled experiments.
*/
public function get_enabled_experiments(): array {
return array_filter(
wp_list_pluck( $this->get_experiments(), 'name' ),
[ $this, 'is_experiment_enabled' ]
);
}
/**
* Returns an experiment by name.
*
* @since 1.3.0
*
* @param string $name Experiment name.
* @return array|null Experiment if found, null otherwise.
*
* @phpstan-return Experiment|null
*/
protected function get_experiment( string $name ): ?array {
$experiment = wp_list_filter( $this->get_experiments(), [ 'name' => $name ] );
return ! empty( $experiment ) ? array_shift( $experiment ) : null;
}
}

View File

@@ -0,0 +1,136 @@
<?php
/**
* Class Font_Post_Type.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\REST_API\Font_Controller;
/**
* Class Font_Post_Type.
*
* @phpstan-import-type PostTypeArgs from \Google\Web_Stories\Post_Type_Base
*/
class Font_Post_Type extends Post_Type_Base implements HasRequirements {
/**
* The slug of the font post type.
*/
public const POST_TYPE_SLUG = 'web-story-font';
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Font_Post_Type constructor.
*
* @since 1.16.0
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
}
/**
* Get post type slug.
*
* @since 1.16.0
*/
public function get_slug(): string {
return self::POST_TYPE_SLUG;
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.16.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type' ];
}
/**
* Registers the post type for fonts.
*
* @since 1.16.0
*
* @return array<string, mixed> Post type args.
*
* @phpstan-return PostTypeArgs
*/
protected function get_args(): array {
/**
* The edit_posts capability.
*
* @var string $edit_posts
*/
$edit_posts = $this->story_post_type->get_cap_name( 'edit_posts' );
// Only admins are allowed to modify custom fonts,
// but anyone who can create stories should be able to use them.
$capabilities = [
'edit_post' => 'manage_options',
'read_post' => $edit_posts,
'delete_post' => 'manage_options',
'edit_posts' => 'manage_options',
'edit_others_posts' => 'manage_options',
'delete_posts' => 'manage_options',
'publish_posts' => 'manage_options',
'read_private_posts' => 'manage_options',
'delete_private_posts' => 'manage_options',
'delete_published_posts' => 'manage_options',
'delete_others_posts' => 'manage_options',
'edit_private_posts' => 'manage_options',
'edit_published_posts' => 'manage_options',
'create_posts' => 'manage_options',
];
return [
'supports' => [
'title',
],
'capabilities' => $capabilities,
'rewrite' => false,
'public' => false,
'show_ui' => false,
'show_in_rest' => true,
'rest_namespace' => self::REST_NAMESPACE,
'rest_base' => 'fonts',
'rest_controller_class' => Font_Controller::class,
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* Interface Conditional.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Something that can be instantiated conditionally.
*
* A class marked as being conditionally can be asked whether it should be
* instantiated through a static method. An example would be a service that is
* only available on the admin backend.
*
* This allows for a more systematic and automated optimization of how the
* different parts of the plugin are enabled or disabled.
*
* @internal
*
* @since 1.6.0
*/
interface Conditional {
/**
* Check whether the conditional object is currently needed.
*
* @since 1.6.0
*
* @return bool Whether the conditional object is needed.
*/
public static function is_needed(): bool;
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* Interface Delayed.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Something that is delayed to a later point in the execution flow.
*
* A class marked as being delayed can return the action at which it requires
* to be registered.
*
* This can be used to only register a given object after certain contextual
* requirements are met, like registering a frontend rendering service only
* after the loop has been set up.
*
* @internal
*
* @since 1.6.0
*/
interface Delayed {
/**
* Get the action to use for registering the service.
*
* @since 1.6.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string;
/**
* Get the action priority to use for registering the service.
*
* @since 1.6.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int;
}

View File

@@ -0,0 +1,29 @@
<?php
/**
* Interface HasMeta.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Class registers meta.
*
* @internal
*
* @since 1.15.0
*/
interface HasMeta {
/**
* Register meta
*
* @since 1.15.0
*/
public function register_meta(): void;
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* Interface HasRequirements.
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Something that requires other services to be registered before it can be registered.
*
* A class marked as having requirements can return the list of services it requires
* to be available before it can be registered.
*
* @internal
*
* @since 1.10.0
*/
interface HasRequirements {
/**
* Get the list of service IDs required for this service to be registered.
*
* @since 1.11.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array;
}

View File

@@ -0,0 +1,126 @@
<?php
/**
* Interface Injector.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* The dependency injector should be the only piece of code doing actual
* instantiations, with the following exceptions:
* - Factories can instantiate directly.
* - Value objects should be instantiated directly where they are being used.
*
* Through technical features like "binding" interfaces to classes or
* "auto-wiring" to resolve all dependency of a class to be instantiated
* automatically, the dependency injector allows for the largest part of the
* code to adhere to the "Code against Interfaces, not Implementations"
* principle.
*
* Finally, the dependency injector should be the only one to decide what
* objects to "share" (always handing out the same instance) or not to share
* (always returning a fresh new instance on each subsequent call). This
* effectively gets rid of the dreaded Singletons.
*
* @internal
*
* @since 1.6.0
*
* @template T
*/
interface Injector extends Service {
/**
* Make an object instance out of an interface or class.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class to make an object instance out of.
* @param string[] $arguments Optional. Additional arguments to pass to the constructor.
* Defaults to an empty array.
* @return C Instantiated object.
*
* @phpstan-param class-string<C> $interface_or_class Interface or class to make an object instance out of.
* @phpstan-param class-string[] $arguments Optional. Additional arguments to pass to the constructor.
* Defaults to an empty array.
*
* @phpstan-return C
*
* @template C
*/
public function make( string $interface_or_class, array $arguments = [] );
/**
* Bind a given interface or class to an implementation.
*
* Note: The implementation can be an interface as well, as long as it can
* be resolved to an instantiatable class at runtime.
*
* @since 1.6.0
*
* @param string $from Interface or class to bind an implementation to.
* @param string $to Interface or class that provides the implementation.
*
* @phpstan-param class-string<T> $from Interface or class to bind an implementation to.
* @phpstan-param class-string<T> $to Interface or class that provides the implementation.
*/
public function bind( string $from, string $to ): Injector;
/**
* Bind an argument for a class to a specific value.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class to bind an argument
* for.
* @param string $argument_name Argument name to bind a value to.
* @param mixed $value Value to bind the argument to.
*
* @phpstan-param class-string<T> $interface_or_class Interface or class to bind an argument
*/
public function bind_argument(
string $interface_or_class,
string $argument_name,
$value
): Injector;
/**
* Always reuse and share the same instance for the provided interface or
* class.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class to reuse.
*
* @phpstan-param class-string<T> $interface_or_class Interface or class to reuse.
*/
public function share( string $interface_or_class ): Injector;
/**
* Delegate instantiation of an interface or class to a callable.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class to delegate the
* instantiation of.
* @param callable $callback Callable to use for instantiation.
*
* @phpstan-param class-string<T> $interface_or_class Interface or class to delegate the
* instantiation of.
*/
public function delegate( string $interface_or_class, callable $callback ): Injector;
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* Final class FallbackInstantiator.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure\Injector;
use Google\Web_Stories\Infrastructure\Instantiator;
/**
* Fallback instantiator to use in case none was provided.
*
* @internal
*
* @since 1.6.0
*/
final class FallbackInstantiator implements Instantiator {
/**
* Make an object instance out of an interface or class.
*
* @since 1.6.0
*
* @param class-string $class_name Class to make an object instance out of.
* @param array<int, mixed> $dependencies Optional. Dependencies of the class.
* @return T Instantiated object.
*
* @template T
*
* @phpstan-param class-string<T> $class_name Class to make an object instance out of.
*/
public function instantiate( string $class_name, array $dependencies = [] ) {
return new $class_name( ...$dependencies );
}
}

View File

@@ -0,0 +1,146 @@
<?php
/**
* Final class InjectionChain.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure\Injector;
/**
* The injection chain is similar to a trace, keeping track of what we have done
* so far and at what depth within the auto-wiring we currently are.
*
* It is used to detect circular dependencies, and can also be dumped for
* debugging information.
*
* @internal
*
* @since 1.6.0
*
* @template T
*/
final class InjectionChain {
/**
* Chain.
*
* @var string[]
* @phpstan-var class-string<T>[]
*/
private array $chain = [];
/**
* Resolutions.
*
* @var array<bool>
* @phpstan-var array<class-string, bool>
*/
private array $resolutions = [];
/**
* Add class to injection chain.
*
* @since 1.6.0
*
* @param string $class_name Class to add to injection chain.
* @return self Modified injection chain.
*
* @phpstan-param class-string<T> $class_name
*/
public function add_to_chain( string $class_name ): self {
$new_chain = clone $this;
$new_chain->chain[] = $class_name;
return $new_chain;
}
/**
* Add resolution for circular reference detection.
*
* @since 1.6.0
*
* @param string $resolution Resolution to add.
* @return self Modified injection chain.
*
* @phpstan-param class-string $resolution
*/
public function add_resolution( string $resolution ): self {
$new_chain = clone $this;
$new_chain->resolutions[ $resolution ] = true;
return $new_chain;
}
/**
* Get the last class that was pushed to the injection chain.
*
* @since 1.6.0
*
* @throws \LogicException If the injection chain is accessed too early.
*
* @return string Last class pushed to the injection chain.
*
* @phpstan-return class-string<T>
*/
public function get_class(): string {
if ( empty( $this->chain ) ) {
throw new \LogicException(
'Access to injection chain before any resolution was made.'
);
}
return \end( $this->chain ) ?: '';
}
/**
* Get the injection chain.
*
* @since 1.6.0
*
* @return string[] Chain of injections.
*
* @phpstan-return class-string[]
*/
public function get_chain(): array {
return \array_reverse( $this->chain );
}
/**
* Check whether the injection chain already has a given resolution.
*
* @since 1.6.0
*
* @param string $resolution Resolution to check for.
* @return bool Whether the resolution was found.
*
* @phpstan-param class-string<T> $resolution
*/
public function has_resolution( string $resolution ): bool {
return \array_key_exists( $resolution, $this->resolutions );
}
/**
* Check whether the injection chain already encountered a class.
*
* @since 1.6.0
*
* @param string $class_name Class to check.
* @return bool Whether the given class is already part of the chain.
*/
public function is_in_chain( string $class_name ): bool {
return \in_array( $class_name, $this->chain, true );
}
}

View File

@@ -0,0 +1,540 @@
<?php
/**
* Final class SimpleInjector.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure\Injector;
use Google\Web_Stories\Exception\FailedToMakeInstance;
use Google\Web_Stories\Infrastructure\Injector;
use Google\Web_Stories\Infrastructure\Instantiator;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionParameter;
use function array_map;
/**
* A simplified implementation of a dependency injector.
*
* @internal
*
* @since 1.6.0
*
* @template T
*/
final class SimpleInjector implements Injector {
/**
* Special-case index key for handling globally defined named arguments.
*/
public const GLOBAL_ARGUMENTS = '__global__';
/**
* Mappings.
*
* @var string[]
* @phpstan-var class-string<T>[]
*/
private array $mappings = [];
/**
* Shared instances
*
* @var array<T|null>
*/
private array $shared_instances = [];
/**
* Delegates.
*
* @var callable[]
*/
private array $delegates = [];
/**
* Argument mappings.
*
* @var array<string, array<mixed>>
*/
private array $argument_mappings = [
self::GLOBAL_ARGUMENTS => [],
];
/**
* Instantiator.
*
* @var Instantiator<T>
*/
private Instantiator $instantiator;
/**
* Instantiate a SimpleInjector object.
*
* @since 1.6.0
*
* @param Instantiator|null $instantiator Optional. Instantiator to use.
*/
public function __construct( ?Instantiator $instantiator = null ) {
$this->instantiator = $instantiator ?? new FallbackInstantiator();
}
/**
* Make an object instance out of an interface or class.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class to make an object instance out of.
* @param array<string, mixed> $arguments Optional. Additional arguments to pass to the constructor.
* Defaults to an empty array.
* @return T Instantiated object.
*
* @phpstan-param class-string<T> $interface_or_class Interface or class to make an object instance out of.
* @phpstan-param array<string, mixed> $arguments Optional. Additional arguments to pass to the constructor.
*/
public function make( string $interface_or_class, array $arguments = [] ) {
$injection_chain = $this->resolve(
new InjectionChain(),
$interface_or_class
);
$class = $injection_chain->get_class();
if ( $this->has_shared_instance( $class ) ) {
return $this->get_shared_instance( $class );
}
if ( $this->has_delegate( $class ) ) {
$delegate = $this->get_delegate( $class );
$object = $delegate( $class );
} else {
$reflection = $this->get_class_reflection( $class );
$this->ensure_is_instantiable( $reflection );
$dependencies = $this->get_dependencies_for(
$injection_chain,
$reflection,
$arguments
);
$object = $this->instantiator->instantiate( $class, $dependencies );
}
if ( \array_key_exists( $class, $this->shared_instances ) ) {
$this->shared_instances[ $class ] = $object;
}
return $object;
}
/**
* Bind a given interface or class to an implementation.
*
* Note: The implementation can be an interface as well, as long as it can
* be resolved to an instantiatable class at runtime.
*
* @since 1.6.0
*
* @param string $from Interface or class to bind an implementation to.
* @param string $to Interface or class that provides the implementation.
*
* @phpstan-param class-string<T> $from Interface or class to bind an implementation to.
* @phpstan-param class-string<T> $to Interface or class that provides the implementation.
*/
public function bind( string $from, string $to ): Injector {
$this->mappings[ $from ] = $to;
return $this;
}
/**
* Bind an argument for a class to a specific value.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class to bind an argument for.
* @param string $argument_name Argument name to bind a value to.
* @param mixed $value Value to bind the argument to.
*/
public function bind_argument(
string $interface_or_class,
string $argument_name,
$value
): Injector {
$this->argument_mappings[ $interface_or_class ][ $argument_name ] = $value;
return $this;
}
/**
* Always reuse and share the same instance for the provided interface or
* class.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class to reuse.
*
* @phpstan-param class-string<T> $interface_or_class Interface or class to reuse.
*/
public function share( string $interface_or_class ): Injector {
$this->shared_instances[ $interface_or_class ] = null;
return $this;
}
/**
* Delegate instantiation of an interface or class to a callable.
*
* @since 1.6.0
*
* @param string $interface_or_class Interface or class to delegate the instantiation of.
* @param callable $callback Callable to use for instantiation.
*
* @phpstan-param class-string<T> $interface_or_class Interface or class to delegate the instantiation of.
*/
public function delegate( string $interface_or_class, callable $callback ): Injector {
$this->delegates[ $interface_or_class ] = $callback;
return $this;
}
/**
* Make an object instance out of an interface or class.
*
* @since 1.6.0
*
* @param InjectionChain $injection_chain Injection chain to track resolutions.
* @param string $interface_or_class Interface or class to make an object instance out of.
* @return T Instantiated object.
*
* @phpstan-param class-string<T> $interface_or_class Interface or class to make an object instance out of.
*/
private function make_dependency(
InjectionChain $injection_chain,
string $interface_or_class
) {
$injection_chain = $this->resolve(
$injection_chain,
$interface_or_class
);
$class = $injection_chain->get_class();
if ( $this->has_shared_instance( $class ) ) {
return $this->get_shared_instance( $class );
}
if ( $this->has_delegate( $class ) ) {
$delegate = $this->get_delegate( $class );
return $delegate( $class );
}
$reflection = $this->get_class_reflection( $class );
$this->ensure_is_instantiable( $reflection );
$dependencies = $this->get_dependencies_for(
$injection_chain,
$reflection
);
$object = $this->instantiator->instantiate( $class, $dependencies );
if ( \array_key_exists( $class, $this->shared_instances ) ) {
$this->shared_instances[ $class ] = $object;
}
return $object;
}
/**
* Recursively resolve an interface to the class it should be bound to.
*
* @since 1.6.0
*
* @throws FailedToMakeInstance If a circular reference was detected.
*
* @param InjectionChain $injection_chain Injection chain to track resolutions.
* @param string $interface_or_class Interface or class to resolve.
* @return InjectionChain Modified Injection chain
*
* @phpstan-param class-string<T> $interface_or_class Interface or class to resolve.
*/
private function resolve(
InjectionChain $injection_chain,
string $interface_or_class
): InjectionChain {
if ( $injection_chain->is_in_chain( $interface_or_class ) ) {
// Circular reference detected, aborting.
throw FailedToMakeInstance::for_circular_reference(
$interface_or_class, // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
$injection_chain // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
);
}
$injection_chain = $injection_chain->add_resolution( $interface_or_class );
if ( \array_key_exists( $interface_or_class, $this->mappings ) ) {
return $this->resolve(
$injection_chain,
$this->mappings[ $interface_or_class ]
);
}
return $injection_chain->add_to_chain( $interface_or_class );
}
/**
* Get the array of constructor dependencies for a given reflected class.
*
* @since 1.6.0
*
* @param InjectionChain $injection_chain Injection chain to track resolutions.
* @param ReflectionClass $reflection Reflected class to get the dependencies for.
* @param array<string, mixed> $arguments Associative array of directly provided arguments.
* @return array<int, mixed> Array of dependencies that represent the arguments for the class' constructor.
*/
private function get_dependencies_for(
InjectionChain $injection_chain,
ReflectionClass $reflection,
array $arguments = []
): array {
$constructor = $reflection->getConstructor();
$class = $reflection->getName();
if ( null === $constructor ) {
return [];
}
return array_map(
fn( ReflectionParameter $parameter ) => $this->resolve_argument(
$injection_chain,
$class,
$parameter,
$arguments
),
$constructor->getParameters()
);
}
/**
* Ensure that a given reflected class is instantiable.
*
* @since 1.6.0
*
* @throws FailedToMakeInstance If the interface could not be resolved.
*
* @param ReflectionClass $reflection Reflected class to check.
*/
private function ensure_is_instantiable( ReflectionClass $reflection ): void {
if ( ! $reflection->isInstantiable() ) {
throw FailedToMakeInstance::for_unresolved_interface( $reflection->getName() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
}
/**
* Resolve a given reflected argument.
*
* @since 1.6.0
*
* @param InjectionChain $injection_chain Injection chain to track resolutions.
* @param string $class_name Name of the class to resolve the arguments for.
* @param ReflectionParameter $parameter Parameter to resolve.
* @param array<string, mixed> $arguments Associative array of directly provided arguments.
* @return mixed Resolved value of the argument.
*/
private function resolve_argument(
InjectionChain $injection_chain,
string $class_name,
ReflectionParameter $parameter,
array $arguments
) {
if ( ! $parameter->hasType() ) {
return $this->resolve_argument_by_name(
$class_name,
$parameter,
$arguments
);
}
$type = $parameter->getType();
// In PHP 8.0, the isBuiltin method was removed from the parent {@see ReflectionType} class.
if ( null === $type || ( $type instanceof ReflectionNamedType && $type->isBuiltin() ) ) {
return $this->resolve_argument_by_name(
$class_name,
$parameter,
$arguments
);
}
/**
* Interface or class.
*
* @var class-string<T> $type
*/
$type = $type instanceof ReflectionNamedType
? $type->getName()
: (string) $type;
return $this->make_dependency( $injection_chain, $type );
}
/**
* Resolve a given reflected argument by its name.
*
* @since 1.6.0
*
* @throws FailedToMakeInstance If the argument could not be resolved.
*
* @param string $class_name Class to resolve the argument for.
* @param ReflectionParameter $parameter Argument to resolve by name.
* @param array<string, mixed> $arguments Associative array of directly provided arguments.
* @return mixed Resolved value of the argument.
*/
private function resolve_argument_by_name(
string $class_name,
ReflectionParameter $parameter,
array $arguments
) {
$name = $parameter->getName();
// The argument was directly provided to the make() call.
if ( \array_key_exists( $name, $arguments ) ) {
return $arguments[ $name ];
}
// Check if we have mapped this argument for the specific class.
if ( \array_key_exists( $class_name, $this->argument_mappings )
&& \array_key_exists( $name, $this->argument_mappings[ $class_name ] ) ) {
$value = $this->argument_mappings[ $class_name ][ $name ];
// Closures are immediately resolved, to provide lazy resolution.
if ( \is_callable( $value ) ) {
$value = $value( $class_name, $parameter, $arguments );
}
return $value;
}
// No argument found for the class, check if we have a global value.
if ( \array_key_exists( $name, $this->argument_mappings[ self::GLOBAL_ARGUMENTS ] ) ) {
return $this->argument_mappings[ self::GLOBAL_ARGUMENTS ][ $name ];
}
// No provided argument found, check if it has a default value.
try {
if ( $parameter->isDefaultValueAvailable() ) {
return $parameter->getDefaultValue();
}
} catch ( \Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Just fall through into the FailedToMakeInstance exception.
}
// Out of options, fail with an exception.
throw FailedToMakeInstance::for_unresolved_argument( $name, $class_name ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
/**
* Check whether a shared instance exists for a given class.
*
* @since 1.6.0
*
* @param string $class_name Class to check for a shared instance.
* @return bool Whether a shared instance exists.
*/
private function has_shared_instance( string $class_name ): bool {
return \array_key_exists( $class_name, $this->shared_instances )
&& null !== $this->shared_instances[ $class_name ];
}
/**
* Get the shared instance for a given class.
*
* @since 1.6.0
*
* @throws FailedToMakeInstance If an uninstantiated shared instance is requested.
*
* @param string $class_name Class to get the shared instance for.
* @return T Shared instance.
*
* @phpstan-param class-string<T> $class_name Class to get the shared instance for.
* @phpstan-return T Shared instance.
*/
private function get_shared_instance( string $class_name ) {
if ( ! $this->has_shared_instance( $class_name ) ) {
throw FailedToMakeInstance::for_uninstantiated_shared_instance( $class_name ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
/**
* Shared instance.
*
* @var T $instance
*/
$instance = $this->shared_instances[ $class_name ];
return $instance;
}
/**
* Check whether a delegate exists for a given class.
*
* @since 1.6.0
*
* @param string $class_name Class to check for a delegate.
* @return bool Whether a delegate exists.
*/
private function has_delegate( string $class_name ): bool {
return \array_key_exists( $class_name, $this->delegates );
}
/**
* Get the delegate for a given class.
*
* @since 1.6.0
*
* @throws FailedToMakeInstance If an invalid delegate is requested.
*
* @param string $class_name Class to get the delegate for.
* @return callable Delegate.
*/
private function get_delegate( string $class_name ): callable {
if ( ! $this->has_delegate( $class_name ) ) {
throw FailedToMakeInstance::for_invalid_delegate( $class_name ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
return $this->delegates[ $class_name ];
}
/**
* Get the reflection for a class or throw an exception.
*
* @since 1.6.0
*
* @throws FailedToMakeInstance If the class could not be reflected.
*
* @param string|class-string $class_name Class to get the reflection for.
* @return ReflectionClass Class reflection.
*/
private function get_class_reflection( string $class_name ): ReflectionClass {
if ( ! class_exists( $class_name ) ) {
throw FailedToMakeInstance::for_unreflectable_class( $class_name ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// There should be no ReflectionException happening because of the class existence check above.
return new ReflectionClass( $class_name );
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Interface Instantiator.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Interface to make the act of instantiation extensible/replaceable.
*
* This way, a more elaborate mechanism can be plugged in, like using
* ProxyManager to instantiate proxies instead of actual objects.
*
* @internal
*
* @since 1.6.0
*
* @template T
*/
interface Instantiator {
/**
* Make an object instance out of an interface or class.
*
* @since 1.6.0
*
* @param string $class_name Class to make an object instance out of.
* @param array<int, mixed> $dependencies Optional. Dependencies of the class.
* @return T Instantiated object.
*
* @phpstan-param class-string<T> $class_name Class to make an object instance out of.
*/
public function instantiate( string $class_name, array $dependencies = [] );
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* Interface Plugin
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* A plugin is basically nothing more than a convention on how manage the
* lifecycle of a modular piece of code, so that you can:
* 1. activate it,
* 2. register it with the framework, and
* 3. deactivate it again.
*
* This is what this interface represents, by assembling the separate,
* segregated interfaces for each of these lifecycle actions.
*
* Additionally, we provide a means to get access to the plugin's container that
* collects all the services it is made up of. This allows direct access to the
* services to outside code if needed.
*
* @internal
*
* @since 1.6.0
*/
interface Plugin extends PluginActivationAware, PluginDeactivationAware, Registerable, SiteInitializationAware, SiteRemovalAware {
/**
* Get the service container that contains the services that make up the
* plugin.
*
* @since 1.6.0
*
* @return ServiceContainer<Service> Service container of the plugin.
*/
public function get_container(): ServiceContainer;
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* Interface PluginActivationAware.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Something that can be activated.
*
* By tagging a service with this interface, the system will automatically hook
* it up to the WordPress activation hook.
*
* This way, we can just add the simple interface marker and not worry about how
* to wire up the code to reach that part during the static activation hook.
*
* @internal
*
* @since 1.6.0
*/
interface PluginActivationAware {
/**
* Act on plugin activation.
*
* @since 1.6.0
*
* @param bool $network_wide Whether the activation was done network-wide.
*/
public function on_plugin_activation( bool $network_wide ): void;
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* Interface PluginDeactivationAware.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Something that can be deactivated.
*
* By tagging a service with this interface, the system will automatically hook
* it up to the WordPress deactivation hook.
*
* This way, we can just add the simple interface marker and not worry about how
* to wire up the code to reach that part during the static deactivation hook.
*
* @internal
*
* @since 1.6.0
*/
interface PluginDeactivationAware {
/**
* Act on plugin deactivation.
*
* @since 1.6.0
*
* @param bool $network_wide Whether the deactivation was done network-wide.
*/
public function on_plugin_deactivation( bool $network_wide ): void;
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Interface PluginUninstallAware.
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Something that can be uninstalled.
*
* By tagging a service with this interface, the system will automatically hook
* it up to the WordPress uninstall hook.
*
* This way, we can just add the simple interface marker and not worry about how
* to wire up the code to reach that part during the static uninstall hook.
*
* @internal
*
* @since 1.26.0
*/
interface PluginUninstallAware {
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void;
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* Interface Registerable.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* Something that can be registered.
*
* For a clean code base, a class instantiation should never have side-effects,
* only initialize the internals of the object so that it is ready to be used.
*
* This means, though, that the system does not have any knowledge of the
* objects when they are merely instantiated.
*
* Registering such an object is the explicit act of making it known to the
* overarching system.
*
* @internal
*
* @since 1.6.0
*/
interface Registerable {
/**
* Register the service.
*
* @since 1.6.0
*/
public function register(): void;
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* Interface Service.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
/**
* A conceptual service.
*
* Splitting the logic up into independent services makes the approach of
* assembling a plugin more systematic and scalable and lowers the cognitive
* load when the code base increases in size.
*
* @internal
*
* @since 1.6.0
*/
interface Service {
}

View File

@@ -0,0 +1,919 @@
<?php
/**
* Class ServiceBasedPlugin.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
use Google\Web_Stories\Exception\InvalidService;
use Google\Web_Stories\Infrastructure\ServiceContainer\LazilyInstantiatedService;
use WP_Site;
use function add_action;
use function apply_filters;
use function did_action;
use const WPCOM_IS_VIP_ENV;
/**
* This abstract base plugin provides all the boilerplate code for working with
* the dependency injector and the service container.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @template C of Conditional
* @template D of Delayed
* @template H of HasRequirements
* @template R of Registerable
* @template S of Service
*/
abstract class ServiceBasedPlugin implements Plugin {
// Main filters to control the flow of the plugin from outside code.
public const SERVICES_FILTER = 'services';
public const BINDINGS_FILTER = 'bindings';
public const ARGUMENTS_FILTER = 'arguments';
public const SHARED_INSTANCES_FILTER = 'shared_instances';
public const DELEGATIONS_FILTER = 'delegations';
// Service identifier for the injector.
public const INJECTOR_ID = 'injector';
// WordPress action to trigger the service registration on.
// Use false to register as soon as the code is loaded.
public const REGISTRATION_ACTION = false;
// Whether to enable filtering by default or not.
public const ENABLE_FILTERS_DEFAULT = true;
// Prefixes to use.
public const HOOK_PREFIX = '';
public const SERVICE_PREFIX = '';
// Pattern used for detecting capitals to turn PascalCase into snake_case.
public const DETECT_CAPITALS_REGEX_PATTERN = '/[A-Z]([A-Z](?![a-z]))*/';
/**
* Enable filters.
*/
protected bool $enable_filters;
/**
* Injector.
*/
protected Injector $injector;
/**
* ServiceContainer.
*
* @var ServiceContainer<Service>
*/
protected ServiceContainer $service_container;
/**
* Instantiate a Theme object.
*
* @since 1.6.0
*
* @param bool|null $enable_filters Optional. Whether to enable filtering of the injector configuration.
* @param Injector|null $injector Optional. Injector instance to use.
* @param ServiceContainer<Service>|null $service_container Optional. Service container instance to use.
*/
public function __construct(
?bool $enable_filters = null,
?Injector $injector = null,
?ServiceContainer $service_container = null
) {
/*
* We use what is commonly referred to as a "poka-yoke" here.
*
* We need an injector and a container. We make them injectable so that
* we can easily provide overrides for testing, but we also make them
* optional and provide default implementations for easy regular usage.
*/
$this->enable_filters = $enable_filters ?? static::ENABLE_FILTERS_DEFAULT;
$this->injector = $injector ?? new Injector\SimpleInjector();
$this->injector = $this->configure_injector( $this->injector );
$this->service_container = $service_container ?? new ServiceContainer\SimpleServiceContainer();
}
/**
* Act on plugin activation.
*
* @since 1.6.0
*
* @param bool $network_wide Whether the activation was done network-wide.
*/
public function on_plugin_activation( bool $network_wide ): void {
$this->register_services();
/**
* Service ID.
*
* @var string $id
*/
foreach ( $this->service_container as $id => $service ) {
// Using ->get() here to instantiate LazilyInstantiatedService too.
$service = $this->service_container->get( $id );
if ( $service instanceof PluginActivationAware ) {
$service->on_plugin_activation( $network_wide );
}
}
if ( ! \defined( '\WPCOM_IS_VIP_ENV' ) || false === WPCOM_IS_VIP_ENV ) {
flush_rewrite_rules( false );
}
}
/**
* Act on plugin deactivation.
*
* @since 1.6.0
*
* @param bool $network_wide Whether the deactivation was done network-wide.
*/
public function on_plugin_deactivation( bool $network_wide ): void {
$this->register_services();
/**
* Service ID.
*
* @var string $id
*/
foreach ( $this->service_container as $id => $service ) {
// Using ->get() here to instantiate LazilyInstantiatedService too.
$service = $this->service_container->get( $id );
if ( $service instanceof PluginDeactivationAware ) {
$service->on_plugin_deactivation( $network_wide );
}
}
if ( ! \defined( '\WPCOM_IS_VIP_ENV' ) || false === WPCOM_IS_VIP_ENV ) {
flush_rewrite_rules( false );
}
}
/**
* Act on site initialization on Multisite.
*
* @since 1.11.0
*
* @param WP_Site $site The site being initialized.
*/
public function on_site_initialization( WP_Site $site ): void {
$this->register_services();
$site_id = (int) $site->blog_id;
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.switch_to_blog_switch_to_blog
switch_to_blog( $site_id );
/**
* Service ID.
*
* @var string $id
*/
foreach ( $this->service_container as $id => $service ) {
// Using ->get() here to instantiate LazilyInstantiatedService too.
$service = $this->service_container->get( $id );
if ( $service instanceof SiteInitializationAware ) {
$service->on_site_initialization( $site );
}
}
if ( ! \defined( '\WPCOM_IS_VIP_ENV' ) || false === WPCOM_IS_VIP_ENV ) {
flush_rewrite_rules( false );
}
restore_current_blog();
}
/**
* Act on site removal on Multisite.
*
* @since 1.11.0
*
* @param WP_Site $site The site being removed.
*/
public function on_site_removal( WP_Site $site ): void {
$this->register_services();
$site_id = (int) $site->blog_id;
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.switch_to_blog_switch_to_blog
switch_to_blog( $site_id );
/**
* Service ID.
*
* @var string $id
*/
foreach ( $this->service_container as $id => $service ) {
// Using ->get() here to instantiate LazilyInstantiatedService too.
$service = $this->service_container->get( $id );
if ( $service instanceof SiteRemovalAware ) {
$service->on_site_removal( $site );
}
}
restore_current_blog();
}
/**
* Act on site is uninstalled.
*
* @since 1.26.0
*/
public function on_site_uninstall(): void {
$this->register_services();
/**
* Service ID.
*
* @var string $id
*/
foreach ( $this->service_container as $id => $service ) {
// Using ->get() here to instantiate LazilyInstantiatedService too.
$service = $this->service_container->get( $id );
if ( $service instanceof PluginUninstallAware ) {
$service->on_plugin_uninstall();
}
}
}
/**
* Register the plugin with the WordPress system.
*
* @since 1.6.0
*
* @throws InvalidService If a service is not valid.
*/
public function register(): void {
if ( false !== static::REGISTRATION_ACTION ) {
add_action(
static::REGISTRATION_ACTION,
[ $this, 'register_services' ]
);
} else {
$this->register_services();
}
}
/**
* Register the individual services of this plugin.
*
* @since 1.6.0
*
* @throws InvalidService If a service is not valid.
*/
public function register_services(): void {
// Bail early so we don't instantiate services twice.
if ( \count( $this->service_container ) > 0 ) {
return;
}
// Add the injector as the very first service.
$this->service_container->put(
static::SERVICE_PREFIX . static::INJECTOR_ID,
$this->injector
);
$services = $this->get_service_classes();
if ( $this->enable_filters ) {
/**
* Filter the default services that make up this plugin.
*
* This can be used to add services to the service container for
* this plugin.
*
* @param array<string, string> $services Associative array of identifier =>
* class mappings. The provided
* classes need to implement the
* Service interface.
*/
$filtered_services = apply_filters(
static::HOOK_PREFIX . static::SERVICES_FILTER,
$services
);
$services = $this->validate_services( $filtered_services );
}
while ( null !== key( $services ) ) {
$id = $this->maybe_resolve( key( $services ) );
$class = $this->maybe_resolve( current( $services ) );
// Delay registering the service until all requirements are met.
if (
is_a( $class, HasRequirements::class, true )
) {
if ( ! $this->requirements_are_met( $id, $class, $services ) ) {
continue;
}
}
$this->schedule_potential_service_registration( $id, $class );
next( $services );
}
}
/**
* Get the service container that contains the services that make up the
* plugin.
*
* @since 1.6.0
*
* @return ServiceContainer<Service> Service container of the plugin.
*/
public function get_container(): ServiceContainer {
return $this->service_container;
}
/**
* Returns the priority for a given service based on its requirements.
*
* @since 1.13.0
*
* @throws InvalidService If the required service is not recognized.
*
* @param class-string $class_name Service FQCN of the service with requirements.
* @param array<string, class-string<S>> $services List of services to be registered.
* @return int The registration action priority for the service.
*
* @phpstan-param class-string<S> $class_name Service FQCN of the service with requirements.
*/
protected function get_registration_action_priority( string $class_name, array &$services ): int {
$priority = 10;
if ( is_a( $class_name, Delayed::class, true ) ) {
$priority = $class_name::get_registration_action_priority();
}
if ( ! is_a( $class_name, HasRequirements::class, true ) ) {
return $priority;
}
/**
* Service class.
*
* @phpstan-var class-string<H&S> $class_name
*/
$missing_requirements = $this->collect_missing_requirements( $class_name, $services );
foreach ( $missing_requirements as $missing_requirement ) {
if ( is_a( $missing_requirement, Delayed::class, true ) ) {
$action = $missing_requirement::get_registration_action();
if ( did_action( $action ) ) {
continue;
}
/**
* Missing requirement.
*
* @phpstan-var class-string<S> $missing_requirement
*/
$requirement_priority = $this->get_registration_action_priority( $missing_requirement, $services );
$priority = max( $priority, $requirement_priority + 1 );
}
}
return $priority;
}
/**
* Determine if the requirements for a service to be registered are met.
*
* This also hooks up another check in the future to the registration action(s) of its requirements.
*
* @since 1.10.0
*
* @throws InvalidService If the required service is not recognized.
*
* @param string $id Service ID of the service with requirements.
* @param class-string $class_name Service FQCN of the service with requirements.
* @param array<string, class-string<S>> $services List of services to be registered.
* @return bool Whether the requirements for the service has been met.
*
* @phpstan-param class-string<H&S> $class_name Service FQCN of the service with requirements.
*/
protected function requirements_are_met( string $id, string $class_name, array &$services ): bool {
$missing_requirements = $this->collect_missing_requirements( $class_name, $services );
if ( empty( $missing_requirements ) ) {
return true;
}
foreach ( $missing_requirements as $missing_requirement ) {
if ( is_a( $missing_requirement, Delayed::class, true ) ) {
$action = $missing_requirement::get_registration_action();
if ( did_action( $action ) ) {
continue;
}
/*
* If this service (A) has priority 10 but depends on another service (B) with same priority,
* which itself depends on service (C) also with priority 10, this will ensure correct
* order of registration by increasing priority for each step.
*
* The result will be:
*
* C: priority 10
* B: priority 11
* A: priority 12
*/
$priority = $this->get_registration_action_priority( $class_name, $services );
/*
* The current service depends on another service that is Delayed and hasn't been registered yet
* and for which the registration action has not yet passed.
*
* Therefore, we postpone the registration of the current service up until the requirement's
* action has passed.
*
* We don't register the service right away, though, we will first check whether at that point,
* the requirements have been met.
*
* Note that badly configured requirements can lead to services that will never register at all.
*/
add_action(
$action,
function () use ( $id, $class_name, $services ): void {
if ( ! $this->requirements_are_met( $id, $class_name, $services ) ) {
return;
}
$this->schedule_potential_service_registration( $id, $class_name );
},
$priority
);
next( $services );
return false;
}
}
/*
* The registration actions from all of the requirements were already processed. This means that the missing
* requirement(s) are about to be registered, they just weren't encountered yet while traversing the services
* map. Therefore, we skip registration for now and move this particular service to the end of the service map.
*
* Note: Moving the service to the end of the service map advances the internal array pointer to the next service.
*/
unset( $services[ $id ] );
$services[ $id ] = $class_name;
return false;
}
/**
* Collect the list of missing requirements for a service which has requirements.
*
* @since 1.10.0
*
* @throws InvalidService If the required service is not recognized.
*
* @param class-string $class_name Service FQCN of the service with requirements.
* @param array<string, class-string<S>> $services List of services to register.
* @return array<string, class-string<S>> List of missing requirements as a $service_id => $service_class mapping.
*
* @phpstan-param class-string<H&S> $class_name Service FQCN of the service with requirements.
*/
protected function collect_missing_requirements( string $class_name, array $services ): array {
/**
* Requirements.
*
* @var string[] $requirements
*/
$requirements = $class_name::get_requirements();
/**
* Missing requirements.
*
* @var array<string, class-string<S>>
*/
$missing = [];
foreach ( $requirements as $requirement ) {
// Bail if it requires a service that is not recognized.
if ( ! \array_key_exists( $requirement, $services ) ) {
throw InvalidService::from_service_id( $requirement ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
if ( $this->get_container()->has( $requirement ) ) {
continue;
}
$missing[ $requirement ] = $services[ $requirement ];
}
return $missing;
}
/**
* Validates the services array to make sure it is in a usable shape.
*
* As the array of services could be filtered, we need to ensure it is
* always in a state where it doesn't throw PHP warnings or errors.
*
* @since 1.6.0
*
* @param array<int|string, string|class-string> $services Services to validate.
* @return string[] Validated array of service mappings.
*/
protected function validate_services( array $services ): array {
// Make a copy so we can safely mutate while iterating.
$services_to_check = $services;
foreach ( $services_to_check as $identifier => $fqcn ) {
// Ensure we have valid identifiers we can refer to.
// If not, generate them from the FQCN.
if ( empty( $identifier ) || ! \is_string( $identifier ) ) {
unset( $services[ $identifier ] );
$identifier = $this->get_identifier_from_fqcn( $fqcn );
$services[ $identifier ] = $fqcn;
}
// Verify that the FQCN is valid and points to an existing class.
// If not, skip this service.
if ( empty( $fqcn ) || ! \is_string( $fqcn ) || ! class_exists( $fqcn ) ) {
unset( $services[ $identifier ] );
}
}
return $services;
}
/**
* Generate a valid identifier for a provided FQCN.
*
* @since 1.6.0
*
* @param string $fqcn FQCN to use as base to generate an identifier.
* @return string Identifier to use for the provided FQCN.
*/
protected function get_identifier_from_fqcn( string $fqcn ): string {
// Retrieve the short name from the FQCN first.
$short_name = substr( $fqcn, strrpos( $fqcn, '\\' ) + 1 );
// Turn camelCase or PascalCase into snake_case.
return strtolower(
trim(
(string) preg_replace( self::DETECT_CAPITALS_REGEX_PATTERN, '_$0', $short_name ),
'_'
)
);
}
/**
* Schedule the potential registration of a single service.
*
* This takes into account whether the service registration needs to be delayed or not.
*
* @since 1.12.0
*
* @param string $id ID of the service to register.
* @param class-string $class_name Class of the service to register.
*
* @phpstan-param class-string<(D&S)|S> $class_name Class of the service to register.
*/
protected function schedule_potential_service_registration( string $id, string $class_name ): void {
if ( is_a( $class_name, Delayed::class, true ) ) {
$action = $class_name::get_registration_action();
$priority = $class_name::get_registration_action_priority();
if ( did_action( $action ) ) {
$this->maybe_register_service( $id, $class_name );
} else {
add_action(
$action,
function () use ( $id, $class_name ): void {
$this->maybe_register_service( $id, $class_name );
},
$priority
);
}
} else {
$this->maybe_register_service( $id, $class_name );
}
}
/**
* Register a single service, provided its conditions are met.
*
* @since 1.6.0
*
* @param string $id ID of the service to register.
* @param string $class_name Class of the service to register.
*
* @phpstan-param class-string<S> $class_name Class of the service to register.
*/
protected function maybe_register_service( string $id, string $class_name ): void {
// Ensure we don't register the same service more than once.
if ( $this->service_container->has( $id ) ) {
return;
}
// Only instantiate services that are actually needed.
if ( is_a( $class_name, Conditional::class, true )
&& ! $class_name::is_needed() ) {
return;
}
$service = $this->instantiate_service( $class_name );
$this->service_container->put( $id, $service );
if ( $service instanceof Registerable ) {
$service->register();
}
}
/**
* Instantiate a single service.
*
* @since 1.6.0
*
* @throws InvalidService If the service could not be properly instantiated.
*
* @param class-string|object $class_name Service class to instantiate.
* @return Service Instantiated service.
*
* @phpstan-param class-string<S> $class_name Service class to instantiate.
*/
protected function instantiate_service( $class_name ): Service {
/*
* If the service is not registerable, we default to lazily instantiated
* services here for some basic optimization.
*
* The services will be properly instantiated once they are retrieved
* from the service container.
*/
if ( ! is_a( $class_name, Registerable::class, true ) ) {
return new LazilyInstantiatedService(
fn() => $this->injector->make( $class_name )
);
}
// The service needs to be registered, so instantiate right away.
$service = $this->injector->make( $class_name );
if ( ! $service instanceof Service ) {
throw InvalidService::from_service( $service ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
return $service;
}
/**
* Configure the provided injector.
*
* This method defines the mappings that the injector knows about, and the
* logic it requires to make more complex instantiations work.
*
* For more complex plugins, this should be extracted into a separate
* object
* or into configuration files.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.6.0
*
* @param Injector $injector Injector instance to configure.
* @return Injector Configured injector instance.
*/
protected function configure_injector( Injector $injector ): Injector {
$bindings = $this->get_bindings();
$shared_instances = $this->get_shared_instances();
$arguments = $this->get_arguments();
$delegations = $this->get_delegations();
if ( $this->enable_filters ) {
/**
* Filter the default bindings that are provided by the plugin.
*
* This can be used to swap implementations out for alternatives.
*
* @param array<string> $bindings Associative array of interface =>
* implementation bindings. Both
* should be FQCNs.
*/
$bindings = apply_filters(
static::HOOK_PREFIX . static::BINDINGS_FILTER,
$bindings
);
/**
* Filter the default argument bindings that are provided by the
* plugin.
*
* This can be used to override scalar values.
*
* @param array<class-string, mixed> $arguments Associative array of class =>
* arguments mappings. The arguments
* array maps argument names to
* values.
*/
$arguments = apply_filters(
static::HOOK_PREFIX . static::ARGUMENTS_FILTER,
$arguments
);
/**
* Filter the instances that are shared by default by the plugin.
*
* This can be used to turn objects that were added externally into
* shared instances.
*
* @param array<string> $shared_instances Array of FQCNs to turn
* into shared objects.
*/
$shared_instances = apply_filters(
static::HOOK_PREFIX . static::SHARED_INSTANCES_FILTER,
$shared_instances
);
/**
* Filter the instances that are shared by default by the plugin.
*
* This can be used to turn objects that were added externally into
* shared instances.
*
* @param array<string, callable> $delegations Associative array of class =>
* callable mappings.
*/
$delegations = apply_filters(
static::HOOK_PREFIX . static::DELEGATIONS_FILTER,
$delegations
);
}
foreach ( $bindings as $from => $to ) {
$from = $this->maybe_resolve( $from );
$to = $this->maybe_resolve( $to );
$injector = $injector->bind( $from, $to );
}
/**
* Argument map.
*
* @var array<class-string, array<string|callable|class-string>> $arguments
*/
foreach ( $arguments as $class => $argument_map ) {
$class = $this->maybe_resolve( $class );
foreach ( $argument_map as $name => $value ) {
// We don't try to resolve the $value here, as we might want to
// pass a callable as-is.
$name = $this->maybe_resolve( $name );
$injector = $injector->bind_argument( $class, $name, $value );
}
}
foreach ( $shared_instances as $shared_instance ) {
$shared_instance = $this->maybe_resolve( $shared_instance );
$injector = $injector->share( $shared_instance );
}
/**
* Callable.
*
* @var callable $callable
*/
foreach ( $delegations as $class => $callable ) {
// We don't try to resolve the $callable here, as we want to pass it
// on as-is.
$class = $this->maybe_resolve( $class );
$injector = $injector->delegate( $class, $callable );
}
return $injector;
}
/**
* Get the list of services to register.
*
* @since 1.6.0
*
* @return array<string, class-string<S>> Associative array of identifiers mapped to fully
* qualified class names.
*/
protected function get_service_classes(): array {
return [];
}
/**
* Get the bindings for the dependency injector.
*
* The bindings let you map interfaces (or classes) to the classes that
* should be used to implement them.
*
* @since 1.6.0
*
* @return array<string, class-string<S>> Associative array of fully qualified class names.
*/
protected function get_bindings(): array {
return [];
}
/**
* Get the argument bindings for the dependency injector.
*
* The argument bindings let you map specific argument values for specific
* classes.
*
* @since 1.6.0
*
* @return array<class-string, mixed> Associative array of arrays mapping argument names
* to argument values.
*/
protected function get_arguments(): array {
return [];
}
/**
* Get the shared instances for the dependency injector.
*
* These classes will only be instantiated once by the injector and then
* reused on subsequent requests.
*
* This effectively turns them into singletons, without any of the
* drawbacks of the actual Singleton anti-pattern.
*
* @since 1.6.0
*
* @return array<string> Array of fully qualified class names.
*/
protected function get_shared_instances(): array {
return [];
}
/**
* Get the delegations for the dependency injector.
*
* These are basically factories to provide custom instantiation logic for
* classes.
*
* @since 1.6.0
*
* @return array<callable> Associative array of callables.
*
* @phpstan-return array<class-string<S|H|C>, callable> Associative array of callables.
*/
protected function get_delegations(): array {
return [];
}
/**
* Maybe resolve a value that is a callable instead of a scalar.
*
* Values that are passed through this method can optionally be provided as
* callables instead of direct values and will be evaluated when needed.
*
* @since 1.6.0
*
* @param string|callable|class-string $value Value to potentially resolve.
* @return string|class-string Resolved or unchanged value.
*
* @phpstan-return class-string<C&D&H&R&S> Resolved or unchanged value.
*/
protected function maybe_resolve( $value ): string {
if ( \is_callable( $value ) && ! ( \is_string( $value ) && \function_exists( $value ) ) ) {
$value = $value( $this->injector, $this->service_container );
}
return $value;
}
}

View File

@@ -0,0 +1,74 @@
<?php
/**
* Interface ServiceContainer.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
use ArrayAccess;
use Countable;
use Google\Web_Stories\Exception\InvalidService;
use Traversable;
/**
* The service container collects all services to manage them.
*
* This is based on PSR-11 and should extend that one if Composer dependencies
* are being used. Relying on a standardized interface like PSR-11 means you'll
* be able to easily swap out the implementation for something else later on.
*
* @internal
*
* @since 1.6.0
*
* @see https://www.php-fig.org/psr/psr-11/
*/
interface ServiceContainer extends Traversable, Countable, ArrayAccess {
/**
* Find a service of the container by its identifier and return it.
*
* @since 1.6.0
*
* @throws InvalidService If the service could not be found.
*
* @param string $id Identifier of the service to look for.
* @return Service Service that was requested.
*/
public function get( string $id ): Service;
/**
* Check whether the container can return a service for the given
* identifier.
*
* @since 1.6.0
*
* @param string $id Identifier of the service to look for.
*/
public function has( string $id ): bool;
/**
* Put a service into the container for later retrieval.
*
* @since 1.6.0
*
* @param string $id Identifier of the service to put into the
* container.
* @param Service $service Service to put into the container.
*/
public function put( string $id, Service $service ): void;
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* Final class LazilyInstantiatedService.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure\ServiceContainer;
use Google\Web_Stories\Exception\InvalidService;
use Google\Web_Stories\Infrastructure\Service;
/**
* A service that only gets properly instantiated when it is actually being
* retrieved from the container.
*
* @internal
*
* @since 1.6.0
*/
final class LazilyInstantiatedService implements Service {
/**
* Instantiation of Class.
*
* @var callable
*/
private $instantiation;
/**
* Instantiate a LazilyInstantiatedService object.
*
* @param callable $instantiation Instantiation callable to use.
*/
public function __construct( callable $instantiation ) {
$this->instantiation = $instantiation;
}
/**
* Do the actual service instantiation and return the real service.
*
* @since 1.6.0
*
* @throws InvalidService If the service could not be properly instantiated.
*
* @return Service Properly instantiated service.
*/
public function instantiate(): Service {
$instantiation = $this->instantiation; // Because uniform variable syntax not supported in PHP 5.6.
$service = $instantiation();
if ( ! $service instanceof Service ) {
throw InvalidService::from_service( $service ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
return $service;
}
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* Final class SimpleServiceContainer.
*
* @link https://www.mwpd.io/
*
* @copyright 2019 Alain Schlesser
* @license MIT
*/
/**
* Original code modified for this project.
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure\ServiceContainer;
use ArrayObject;
use Google\Web_Stories\Exception\InvalidService;
use Google\Web_Stories\Infrastructure\Service;
use Google\Web_Stories\Infrastructure\ServiceContainer;
/**
* A simplified implementation of a service container.
*
* We extend ArrayObject so we have default implementations for iterators and
* array access.
*
* @internal
*
* @since 1.6.0
*/
final class SimpleServiceContainer extends ArrayObject implements ServiceContainer {
/**
* Find a service of the container by its identifier and return it.
*
* @since 1.6.0
*
* @throws InvalidService If the service could not be found.
*
* @param string $id Identifier of the service to look for.
* @return Service Service that was requested.
*/
public function get( string $id ): Service {
if ( ! $this->has( $id ) ) {
throw InvalidService::from_service_id( $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
/**
* Service.
*
* @var Service $service Service.
*/
$service = $this->offsetGet( $id );
// Instantiate actual services if they were stored lazily.
if ( $service instanceof LazilyInstantiatedService ) {
$service = $service->instantiate();
$this->put( $id, $service );
}
return $service;
}
/**
* Check whether the container can return a service for the given
* identifier.
*
* @since 1.6.0
*
* @param string $id Identifier of the service to look for.
*/
public function has( string $id ): bool {
return $this->offsetExists( $id );
}
/**
* Put a service into the container for later retrieval.
*
* @since 1.6.0
*
* @param string $id Identifier of the service to put into the
* container.
* @param Service $service Service to put into the container.
*/
public function put( string $id, Service $service ): void {
$this->offsetSet( $id, $service );
}
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* Interface SiteInitializationAware.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
use WP_Site;
/**
* Something that acts on site creation on Multisite.
*
* By tagging a service with this interface, the system will automatically hook
* it up to the 'wp_initialize_site' WordPress action.
*
* @internal
*
* @since 1.11.0
*/
interface SiteInitializationAware {
/**
* Act on site initialization.
*
* @since 1.11.0
*
* @param WP_Site $site The site being initialized.
*/
public function on_site_initialization( WP_Site $site ): void;
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* Interface SiteRemovalAware.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Infrastructure;
use WP_Site;
/**
* Something that acts on site removal on Multisite.
*
* By tagging a service with this interface, the system will automatically hook
* it up to the 'wp_validate_site_deletion' WordPress action.
*
* @internal
*
* @since 1.11.0
*/
interface SiteRemovalAware {
/**
* Act on site removal.
*
* @since 1.11.0
*
* @param WP_Site $site The site being removed.
*/
public function on_site_removal( WP_Site $site ): void;
}

View File

@@ -0,0 +1,462 @@
<?php
/**
* Class AMP
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use DOMElement;
use Google\Web_Stories\AMP\Integration\AMP_Story_Sanitizer;
use Google\Web_Stories\Context;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Model\Story;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Settings;
use Google\Web_Stories\Story_Post_Type;
use WP_Post;
/**
* Class AMP.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type AMPOptions array{
* theme_support?: string,
* supported_post_types?: string[],
* supported_templates?: string[]
* }
*
* @phpstan-type AMPSanitizers array{
* AMP_Style_Sanitizer?: array{
* dynamic_element_selectors?: string[]
* }
* }
*/
class AMP extends Service_Base implements HasRequirements {
/**
* Slug of the AMP validated URL post type.
*/
public const AMP_VALIDATED_URL_POST_TYPE = 'amp_validated_url';
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Analytics constructor.
*
* @since 1.12.0
*
* @param Settings $settings Settings instance.
* @param Story_Post_Type $story_post_type Experiments instance.
* @param Context $context Context instance.
* @return void
*/
public function __construct(
Settings $settings,
Story_Post_Type $story_post_type,
Context $context
) {
$this->settings = $settings;
$this->story_post_type = $story_post_type;
$this->context = $context;
}
/**
* Initializes all hooks.
*
* @since 1.2.0
*/
public function register(): void {
add_filter( 'option_amp-options', [ $this, 'filter_amp_options' ] );
add_filter( 'amp_supportable_post_types', [ $this, 'filter_supportable_post_types' ] );
add_filter( 'amp_to_amp_linking_element_excluded', [ $this, 'filter_amp_to_amp_linking_element_excluded' ], 10, 4 );
add_filter( 'amp_content_sanitizers', [ $this, 'add_amp_content_sanitizers' ] );
add_filter( 'amp_validation_error_sanitized', [ $this, 'filter_amp_validation_error_sanitized' ], 10, 2 );
add_filter( 'amp_skip_post', [ $this, 'filter_amp_skip_post' ], 10, 2 );
// This filter is actually used in this plugin's `Sanitization` class.
add_filter( 'web_stories_amp_validation_error_sanitized', [ $this, 'filter_amp_validation_error_sanitized' ], 10, 2 );
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because settings needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'settings' ];
}
/**
* Filter AMP options to force Standard mode (AMP-first) when a web story is being requested.
*
* @since 1.2.0
*
* @param array|mixed $options Options.
* @return array|mixed Filtered options.
*
* @phpstan-param AMPOptions $options
*
* @template T
*
* @phpstan-return ($options is array<T> ? array<T> : mixed)
*/
public function filter_amp_options( $options ) {
if ( ! \is_array( $options ) ) {
return $options;
}
if ( $this->get_request_post_type() === $this->story_post_type->get_slug() ) {
$options['theme_support'] = 'standard';
$options['supported_post_types'][] = $this->story_post_type->get_slug();
$options['supported_templates'][] = 'is_singular';
}
return $options;
}
/**
* Filter the post types which are supportable.
*
* Remove web-stories from the list unless the currently requested post type is for a web-story. This is done in
* order to hide stories from the list of supportable post types on the AMP Settings screen.
*
* @since 1.2.0
*
* @param string[]|mixed $post_types Supportable post types.
* @return array|mixed Supportable post types.
*
* @template T
*
* @phpstan-return ($post_types is array<T> ? array<T> : mixed)
*/
public function filter_supportable_post_types( $post_types ) {
if ( ! \is_array( $post_types ) ) {
return $post_types;
}
$story_post_type = $this->story_post_type->get_slug();
$post_types = array_diff( $post_types, [ $story_post_type ] );
if ( $this->get_request_post_type() === $story_post_type ) {
$post_types = [ ...$post_types, $story_post_type ];
}
return array_unique( array_values( $post_types ) );
}
/**
* Filters the AMP plugin's sanitizers.
*
* @since 1.2.0
*
* @param array|mixed $sanitizers Sanitizers.
* @return array|mixed Sanitizers.
*
* @phpstan-param AMPSanitizers|mixed $sanitizers
* @phpstan-return AMPSanitizers|mixed
*/
public function add_amp_content_sanitizers( $sanitizers ) {
if ( ! $this->context->is_web_story() ) {
return $sanitizers;
}
$post = get_queried_object();
if ( ! ( $post instanceof WP_Post ) ) {
return $sanitizers;
}
if ( ! \is_array( $sanitizers ) ) {
return $sanitizers;
}
/**
* AMP sanitizer configuration.
*
* @phpstan-var AMPSanitizers $sanitizers
*/
$video_cache_enabled = (bool) $this->settings->get_setting( $this->settings::SETTING_NAME_VIDEO_CACHE );
$story = new Story();
$story->load_from_post( $post );
$poster_images = [
'poster-portrait-src' => esc_url_raw( $story->get_poster_portrait() ),
];
if ( isset( $sanitizers['AMP_Style_Sanitizer'] ) ) {
if ( ! isset( $sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'] ) ) {
$sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'] = [];
}
$sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'][] = 'amp-story-captions';
}
$sanitizers[ AMP_Story_Sanitizer::class ] = [
'publisher_logo' => (string) $story->get_publisher_logo_url(),
'publisher' => $story->get_publisher_name(),
'poster_images' => array_filter( $poster_images ),
'video_cache' => $video_cache_enabled,
'title_tag' => wp_get_document_title(),
'description' => wp_strip_all_tags( get_the_excerpt() ),
];
return $sanitizers;
}
/**
* Filter amp_validation_error_sanitized to prevent invalid markup removal for Web Stories.
*
* Since the amp-story element requires the poster-portrait-src attribute to be valid, when this attribute is absent
* the AMP plugin will try to remove the amp-story element altogether. This is not the preferred resolution! So
* instead, this will force the invalid markup to be kept. When this is done, the AMP plugin in Standard mode
* (which Web Stories enforces while serving singular web-story posts) will remove the amp attribute from the html
* element so that the page will not be advertised as AMP. This prevents GSC from complaining about a validation
* issue which we already know about.
*
* The same is done for <amp-video> elements, for example when they have missing poster images.
*
* @since 1.1.1
*
* @link https://github.com/ampproject/amp-wp/blob/c6aed8f/includes/validation/class-amp-validation-manager.php#L1777-L1809
*
* @param null|bool $sanitized Whether sanitized. Null means sanitization is not overridden.
* @param array{node_type?: int, node_name?: string, parent_name?: string} $error Validation error being sanitized.
* @return null|bool Whether sanitized.
*/
public function filter_amp_validation_error_sanitized( ?bool $sanitized, array $error ): ?bool {
// Skip sanitization for missing publisher logos and poster portrait images.
if (
isset( $error['node_type'], $error['node_name'], $error['parent_name'] ) &&
(
( XML_ELEMENT_NODE === $error['node_type'] && 'amp-story' === $error['node_name'] && 'body' === $error['parent_name'] ) ||
( XML_ATTRIBUTE_NODE === $error['node_type'] && 'poster-portrait-src' === $error['node_name'] && 'amp-story' === $error['parent_name'] ) ||
( XML_ATTRIBUTE_NODE === $error['node_type'] && 'publisher-logo-src' === $error['node_name'] && 'amp-story' === $error['parent_name'] )
)
) {
return false;
}
// Skip sanitization for missing video posters.
if ( isset( $error['node_name'] ) && 'amp-video' === $error['node_name'] ) {
return false;
}
// Skip sanitization for amp-video > source with invalid src.
if ( isset( $error['parent_name'] ) && 'source' === $error['parent_name'] ) {
return false;
}
return $sanitized;
}
/**
* Filters whether AMP-to-AMP is excluded for an element.
*
* The element may be either a link (`a` or `area`) or a `form`.
*
* @since 1.2.0
*
* @param bool|mixed $excluded Excluded. Default value is whether element already has a `noamphtml` link relation or the URL is among `excluded_urls`.
* @param string $url URL considered for exclusion.
* @param string[] $rel Link relations.
* @param DOMElement|null $element The element considered for excluding from AMP-to-AMP linking. May be instance of `a`, `area`, or `form`.
* @return bool|mixed Whether AMP-to-AMP is excluded.
*/
public function filter_amp_to_amp_linking_element_excluded( $excluded, string $url, array $rel, ?DOMElement $element ) {
if ( $element instanceof DOMElement && $element->parentNode instanceof DOMElement && 'amp-story-player' === $element->parentNode->tagName ) {
return true;
}
return $excluded;
}
/**
* Filters whether to skip the post from AMP.
*
* Skips the post if the AMP plugin's version is lower than what is bundled in this plugin.
* Prevents issues where this plugin uses newer features that the plugin doesn't know about yet,
* causing false positives with validation.
*
* @since 1.6.0
*
* @link https://github.com/googleforcreators/web-stories-wp/issues/7131
*
* @param bool|mixed $skipped Whether the post should be skipped from AMP.
* @param int $post Post ID.
* @return bool|mixed Whether post should be skipped from AMP.
*/
public function filter_amp_skip_post( $skipped, int $post ) {
// This is the opposite to the `AMP__VERSION >= WEBSTORIES_AMP_VERSION` check in the HTML renderer.
if (
$this->story_post_type->get_slug() === get_post_type( $post )
&&
\defined( '\AMP__VERSION' )
&&
version_compare( WEBSTORIES_AMP_VERSION, AMP__VERSION, '>' )
) {
return true;
}
return $skipped;
}
/**
* Get the post type for the current request.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.2.0
*/
protected function get_request_post_type(): ?string {
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( did_action( 'wp' ) && is_singular() ) {
$post_type = get_post_type( get_queried_object_id() );
return $post_type ?: null;
}
if (
isset( $_GET['action'], $_GET['post'] ) &&
'amp_validate' === $_GET['action'] &&
is_admin()
) {
/**
* Post ID.
*
* @var string|int $post_id
*/
$post_id = $_GET['post'];
if ( get_post_type( (int) $post_id ) === self::AMP_VALIDATED_URL_POST_TYPE ) {
return $this->get_validated_url_post_type( (int) $post_id );
}
}
$current_screen_post_type = $this->context->get_screen_post_type();
if ( $current_screen_post_type ) {
$current_post = get_post();
if ( self::AMP_VALIDATED_URL_POST_TYPE === $current_screen_post_type && $current_post instanceof WP_Post && $current_post->post_type === $current_screen_post_type ) {
$validated_url_post_type = $this->get_validated_url_post_type( $current_post->ID );
if ( $validated_url_post_type ) {
return $validated_url_post_type;
}
}
return $current_screen_post_type;
}
if ( isset( $_SERVER['REQUEST_URI'] ) ) {
/**
* Request URI.
*
* @var string $request_uri
*/
$request_uri = $_SERVER['REQUEST_URI'];
if ( str_contains( (string) wp_unslash( $request_uri ), $this->story_post_type->get_rest_url() ) ) {
return $this->story_post_type->get_slug();
}
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
return null;
}
/**
* Get the singular post type which is the queried object for the given validated URL post.
*
* @since 1.0.0
*
* @param int $post_id Post ID for Validated URL Post.
* @return string|null Post type or null if validated URL is not for a singular post.
*/
protected function get_validated_url_post_type( int $post_id ): ?string {
if ( empty( $post_id ) ) {
return null;
}
$post = get_post( $post_id );
if ( ! $post instanceof WP_Post ) {
return null;
}
if ( self::AMP_VALIDATED_URL_POST_TYPE !== $post->post_type ) {
return null;
}
/**
* AMP queried object.
*
* @var array{type?: string, id?: int|string}|string $queried_object
*/
$queried_object = get_post_meta( $post->ID, '_amp_queried_object', true );
if ( ! \is_array( $queried_object ) ) {
return null;
}
if ( isset( $queried_object['id'], $queried_object['type'] ) && 'post' === $queried_object['type'] ) {
/**
* Post ID.
*
* @var int|string $post_id
*/
$post_id = $queried_object['id'];
$post_type = get_post_type( (int) $post_id );
if ( $post_type ) {
return $post_type;
}
}
return null;
}
}

View File

@@ -0,0 +1,82 @@
<?php
/**
* Class Conditional_Featured_Image
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Story_Post_Type;
/**
* Class Conditional_Featured_Image.
*/
class Conditional_Featured_Image extends Service_Base {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Conditional_Featured_Image constructor.
*
* @since 1.16.0
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
*/
public function __construct( Story_Post_Type $story_post_type ) {
$this->story_post_type = $story_post_type;
}
/**
* Initializes all hooks.
*
* @since 1.16.0
*/
public function register(): void {
add_filter( 'cybocfi_enabled_for_post_type', [ $this, 'cybocfi_enabled_for_post_type' ], 99, 2 );
}
/**
* Filter the conditional-featured-image plugin.
*
* @since 1.16.0
*
* @param mixed $enabled If enabled or not.
* @param string $post_type Post type slug.
* @return mixed Filter value.
*/
public function cybocfi_enabled_for_post_type( $enabled, string $post_type ) {
if ( $this->story_post_type->get_slug() === $post_type ) {
return false;
}
return $enabled;
}
}

View File

@@ -0,0 +1,170 @@
<?php
/**
* Class Core_Themes_Support
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use Google\Web_Stories\Admin\Customizer;
use Google\Web_Stories\Assets;
use Google\Web_Stories\Renderer\Stories\Renderer;
use Google\Web_Stories\Service_Base;
use function Google\Web_Stories\render_theme_stories;
/**
* Class Core_Themes_Support.
*/
class Core_Themes_Support extends Service_Base {
/**
* Default array of core themes to add support to.
*
* @var string[]
*/
protected static array $supported_themes = [
'twentytwentyone',
'twentytwenty',
'twentynineteen',
'twentyseventeen',
'twentysixteen',
'twentyfifteen',
'twentyfourteen',
'twentythirteen',
'twentytwelve',
'twentyeleven',
'twentyten',
];
/**
* Assets instance.
*
* @var Assets Assets instance.
*/
private Assets $assets;
/**
* Core theme supports constructor.
*
* @since 1.8.0
*
* @param Assets $assets Assets instance.
*/
public function __construct( Assets $assets ) {
$this->assets = $assets;
}
/**
* Adds theme support for Web Stories.
*
* This will enable add_theme_support with predefined
* options supported themes.
*
* @since 1.5.0
*/
public function extend_theme_support(): void {
add_theme_support( 'web-stories' );
}
/**
* Embed Webstories.
*
* Embeds web stories with default customizer settings.
*
* @since 1.5.0
*/
public function embed_web_stories(): void {
$stylesheet = get_stylesheet();
if ( is_readable( sprintf( '%sassets/css/web-stories-theme-style-%s.css', WEBSTORIES_PLUGIN_DIR_PATH, $stylesheet ) ) ) {
$this->assets->enqueue_style_asset( 'web-stories-theme-style-' . $stylesheet, [ Renderer::STYLE_HANDLE ] );
}
?>
<div class="web-stories-theme-header-section">
<?php render_theme_stories(); ?>
</div>
<?php
}
/**
* Add a class if it is one of supported core themes.
*
* @since 1.5.0
*
* @param array|mixed $classes Array of body classes.
* @return array|mixed Updated array of classes.
*
* @template T
*
* @phpstan-return ($classes is array<T> ? array<T> : mixed)
*/
public function add_core_theme_classes( $classes ) {
if ( ! \is_array( $classes ) ) {
return $classes;
}
$classes[] = 'has-web-stories';
return $classes;
}
/**
* Adds theme support and hook to embed the web stories.
*
* @since 1.5.0
*/
public function register(): void {
if ( ! \in_array( get_stylesheet(), self::$supported_themes, true ) ) {
return;
}
$this->extend_theme_support();
/**
* Customizer options.
*
* @var array<string, mixed> $options
*/
$options = get_option( Customizer::STORY_OPTION, [] );
// Load theme specific styles and render function only if selected to show stories.
if ( empty( $options['show_stories'] ) ) {
return;
}
add_filter( 'body_class', [ $this, 'add_core_theme_classes' ] );
add_action( 'wp_body_open', [ $this, 'embed_web_stories' ] );
}
/**
* Get the action to use for registering the service.
*
* @since 1.6.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'after_setup_theme';
}
}

View File

@@ -0,0 +1,144 @@
<?php
/**
* Class Ezoic
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use Google\Web_Stories\AMP\Optimization;
use Google\Web_Stories\AMP\Sanitization;
use Google\Web_Stories\Context;
use Google\Web_Stories\Exception\SanitizationException;
use Google\Web_Stories\Infrastructure\Conditional;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories_Dependencies\AmpProject\Dom\Document;
use Throwable;
/**
* Class Ezoic.
*/
class Ezoic extends Service_Base implements Conditional {
/**
* Sanitization instance.
*
* @var Sanitization Sanitization instance.
*/
private Sanitization $sanitization;
/**
* Optimization instance.
*
* @var Optimization Optimization instance.
*/
private Optimization $optimization;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Constructor.
*
* @since 1.33.0
*
* @param Sanitization $sanitization Sanitization instance.
* @param Optimization $optimization Optimization instance.
* @param Context $context Context instance.
*/
public function __construct( Sanitization $sanitization, Optimization $optimization, Context $context ) {
$this->sanitization = $sanitization;
$this->optimization = $optimization;
$this->context = $context;
}
/**
* Initializes all hooks.
*
* @since 1.33.0
*/
public function register(): void {
add_filter( 'ez_buffered_final_content', [ $this, 'process_ez_buffered_final_content' ] );
}
/**
* Check whether the Ezoic integration is currently needed.
*
* @since 1.33.0
*
* @return bool Whether Ezoic integration is currently needed.
*/
public static function is_needed(): bool {
return \defined( 'EZOIC_INTEGRATION_VERSION' );
}
/**
* Optimizes and Sanitizes Ezoic's final prepared content.
*
* @since 1.33.0
*
* @param string $content HTML document response collected by Ezoic Output Buffer.
* @return string AMP document response.
*/
public function process_ez_buffered_final_content( string $content ): string {
if ( $this->context->is_web_story() ) {
// Enforce UTF-8 encoding as it is a requirement for AMP.
if ( ! headers_sent() ) {
header( 'Content-Type: text/html; charset=utf-8' );
}
$dom = Document::fromHtml( $content );
if ( ! $dom instanceof Document ) {
return $this->render_error_page( SanitizationException::from_document_parse_error() );
}
$this->sanitization->sanitize_document( $dom );
$this->optimization->optimize_document( $dom );
return $dom->saveHTML();
}
return $content;
}
/**
* Render error page.
*
* @since 1.33.0
*
* @param Throwable $throwable Exception or (as of PHP7) Error.
* @return string Error page.
*/
private function render_error_page( Throwable $throwable ): string {
return esc_html__( 'There was an error generating the web story, probably because of a server misconfiguration. Try contacting your hosting provider or open a new support request.', 'web-stories' ) .
"\n" .
"\n" .
// translators: 1: error message. 2: location.
sprintf( esc_html__( 'Error message: %1$s (%2$s)', 'web-stories' ), $throwable->getMessage(), $throwable->getFile() . ':' . $throwable->getLine() );
}
}

View File

@@ -0,0 +1,396 @@
<?php
/**
* Class Jetpack
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use Google\Web_Stories\Context;
use Google\Web_Stories\Media\Media_Source_Taxonomy;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Story_Post_Type;
use WP_Post;
use WP_REST_Response;
/**
* Class Jetpack.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type AttachmentData array{
* media_details?: array{
* length?: int,
* length_formatted?: string
* },
* url?: string,
* featured_media_src?: string
* }
*
* @phpstan-type EnhancedAttachmentMetadata array{
* width: int,
* height: int,
* file: string,
* sizes: mixed,
* image_meta: mixed,
* videopress?: array{
* duration: int,
* poster: string,
* width: int,
* height: int,
* file_url_base?: array{
* https: string
* },
* files?: array{
* hd?: array{
* mp4?: string
* }
* }
* }
* }
*/
class Jetpack extends Service_Base {
/**
* VideoPress Mime type.
*
* @since 1.7.2
*/
public const VIDEOPRESS_MIME_TYPE = 'video/videopress';
/**
* VideoPress poster meta key.
*
* @since 1.7.2
*/
public const VIDEOPRESS_POSTER_META_KEY = 'videopress_poster_image';
/**
* Media_Source_Taxonomy instance.
*
* @var Media_Source_Taxonomy Experiments instance.
*/
protected Media_Source_Taxonomy $media_source_taxonomy;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Jetpack constructor.
*
* @since 1.12.0
*
* @param Media_Source_Taxonomy $media_source_taxonomy Media_Source_Taxonomy instance.
* @param Context $context Context instance.
*/
public function __construct( Media_Source_Taxonomy $media_source_taxonomy, Context $context ) {
$this->media_source_taxonomy = $media_source_taxonomy;
$this->context = $context;
}
/**
* Initializes all hooks.
*
* @since 1.2.0
*/
public function register(): void {
// See https://github.com/Automattic/jetpack/blob/4b85be883b3c584c64eeb2fb0f3fcc15dabe2d30/modules/custom-post-types/portfolios.php#L80.
if ( \defined( 'IS_WPCOM' ) && IS_WPCOM ) {
add_filter( 'wpcom_sitemap_post_types', [ $this, 'add_to_jetpack_sitemap' ] );
} else {
add_filter( 'jetpack_sitemap_post_types', [ $this, 'add_to_jetpack_sitemap' ] );
}
add_filter( 'jetpack_is_amp_request', [ $this, 'force_amp_request' ] );
add_filter( 'web_stories_allowed_mime_types', [ $this, 'add_videopress' ] );
add_filter( 'web_stories_rest_prepare_attachment', [ $this, 'filter_rest_api_response' ], 10, 2 );
add_filter( 'ajax_query_attachments_args', [ $this, 'filter_ajax_query_attachments_args' ] );
add_action( 'added_post_meta', [ $this, 'add_term' ], 10, 3 );
}
/**
* Adds the web-story post type to Jetpack / WordPress.com sitemaps.
*
* @since 1.2.0
*
* @see https://github.com/Automattic/jetpack/blob/4b85be883b3c584c64eeb2fb0f3fcc15dabe2d30/modules/custom-post-types/portfolios.php#L80
*
* @param array|mixed $post_types Array of post types.
* @return array|mixed Modified list of post types.
*
* @template T
*
* @phpstan-return ($post_types is array<T> ? array<T> : mixed)
*/
public function add_to_jetpack_sitemap( $post_types ) {
if ( ! \is_array( $post_types ) ) {
return $post_types;
}
$post_types[] = Story_Post_Type::POST_TYPE_SLUG;
return $post_types;
}
/**
* Add VideoPress to allowed mime types.
*
* If the site does not support VideoPress, this will be filtered out.
*
* @since 1.7.2
*
* @param array{video?: string[]}|mixed $mime_types Associative array of allowed mime types per media type (image, audio, video).
* @return array{video?: string[]}|mixed
*
* @template T
*
* @phpstan-return ($mime_types is array<T> ? array<T> : mixed)
*/
public function add_videopress( $mime_types ) {
if ( ! \is_array( $mime_types ) ) {
return $mime_types;
}
/**
* Mime types config.
*
* @var array{video?: string[]} $mime_types
*/
$mime_types['video'][] = self::VIDEOPRESS_MIME_TYPE;
return $mime_types;
}
/**
* Filter ajax query attachments args when accessed from the Web Stories editor.
*
* Only filters the response if the mime type matches exactly what Web Stories is looking for.
*
* @since 1.7.2
*
* @param array|mixed $args Query args.
* @return array|mixed Filtered query args.
*
* @template T
*
* @phpstan-return ($args is array<T> ? array<T> : mixed)
*/
public function filter_ajax_query_attachments_args( $args ) {
if ( ! \is_array( $args ) || ! isset( $args['post_mime_type'] ) || ! \is_array( $args['post_mime_type'] ) ) {
return $args;
}
if ( \in_array( self::VIDEOPRESS_MIME_TYPE, $args['post_mime_type'], true ) ) {
add_filter( 'wp_prepare_attachment_for_js', [ $this, 'filter_admin_ajax_response' ], 15, 2 );
}
return $args;
}
/**
* Filter admin ajax responses for VideoPress videos.
*
* Changes the video/videopress type back to mp4
* and ensures MP4 source URLs are returned.
*
* @since 1.7.2
*
* @param array|mixed $data Array of prepared attachment data. @see wp_prepare_attachment_for_js().
* @param WP_Post $attachment Attachment object.
* @return array|mixed
*
* @phpstan-param AttachmentData $data
* @phpstan-return AttachmentData|mixed
*
* @template T
*
* @phpstan-return ($data is array<T> ? array<T> : mixed)
*/
public function filter_admin_ajax_response( $data, WP_Post $attachment ) {
if ( self::VIDEOPRESS_MIME_TYPE !== $attachment->post_mime_type ) {
return $data;
}
if ( ! \is_array( $data ) ) {
return $data;
}
// Reset mime type back to mp4, as this is the correct value.
$data['mime'] = 'video/mp4';
$data['subtype'] = 'mp4';
// Mark video as optimized.
$data[ $this->media_source_taxonomy::MEDIA_SOURCE_KEY ] = 'video-optimization';
/**
* Jetpack adds an additional field to regular attachment metadata.
*
* @var array $metadata
* @phpstan-var EnhancedAttachmentMetadata|false $metadata
*/
$metadata = wp_get_attachment_metadata( $attachment->ID );
if ( $metadata && isset( $metadata['videopress']['duration'], $data['media_details'] ) && \is_array( $data['media_details'] ) ) {
$data['media_details']['length_formatted'] = $this->format_milliseconds( $metadata['videopress']['duration'] );
$data['media_details']['length'] = (int) floor( $metadata['videopress']['duration'] / 1000 );
}
if ( $metadata && isset( $data['url'], $metadata['videopress']['file_url_base']['https'], $metadata['videopress']['files']['hd']['mp4'] ) ) {
$data['url'] = $metadata['videopress']['file_url_base']['https'] . $metadata['videopress']['files']['hd']['mp4'];
}
// Get the correct poster with matching dimensions from VideoPress.
if ( $metadata && isset( $data['featured_media_src'], $metadata['videopress']['poster'], $metadata['videopress']['width'], $metadata['videopress']['height'] ) ) {
$data['featured_media_src'] = [
'src' => $metadata['videopress']['poster'],
'width' => $metadata['videopress']['width'],
'height' => $metadata['videopress']['height'],
'generated' => true,
];
}
return $data;
}
/**
* Filter REST API responses for VideoPress videos.
*
* Changes the video/videopress type back to mp4
* and ensures MP4 source URLs are returned.
*
* @since 1.7.2
*
* @param WP_REST_Response $response The response object.
* @param WP_Post $post The original attachment post.
*/
public function filter_rest_api_response( WP_REST_Response $response, WP_Post $post ): WP_REST_Response {
if ( self::VIDEOPRESS_MIME_TYPE !== $post->post_mime_type ) {
return $response;
}
/**
* Response data.
*
* @var array<string, string|array<string, int|string>|bool> $data
*/
$data = $response->get_data();
// Reset mime type back to mp4, as this is the correct value.
$data['mime_type'] = 'video/mp4';
// Mark video as optimized.
$data[ $this->media_source_taxonomy::MEDIA_SOURCE_KEY ] = 'video-optimization';
/**
* Jetpack adds an additional field to regular attachment metadata.
*
* @var EnhancedAttachmentMetadata|false $metadata
*/
$metadata = wp_get_attachment_metadata( $post->ID );
if ( $metadata && isset( $metadata['videopress']['duration'], $data['media_details'] ) && \is_array( $data['media_details'] ) ) {
$data['media_details']['length_formatted'] = $this->format_milliseconds( $metadata['videopress']['duration'] );
$data['media_details']['length'] = (int) floor( $metadata['videopress']['duration'] / 1000 );
}
if ( $metadata && isset( $data['source_url'], $metadata['videopress']['file_url_base']['https'], $metadata['videopress']['files']['hd']['mp4'] ) ) {
$data['source_url'] = $metadata['videopress']['file_url_base']['https'] . $metadata['videopress']['files']['hd']['mp4'];
}
// Get the correct poster with matching dimensions from VideoPress.
if ( $metadata && isset( $data['featured_media_src'], $metadata['videopress']['poster'], $metadata['videopress']['width'], $metadata['videopress']['height'] ) ) {
$data['featured_media_src'] = [
'src' => $metadata['videopress']['poster'],
'width' => $metadata['videopress']['width'],
'height' => $metadata['videopress']['height'],
'generated' => true,
];
}
$response->set_data( $data );
return $response;
}
/**
* Hook into added_post_meta.
*
* @since 1.7.2
*
* @param int $mid The meta ID after successful update.
* @param int $object_id ID of the object metadata is for.
* @param string $meta_key Metadata key.
*/
public function add_term( int $mid, int $object_id, string $meta_key ): void {
if ( self::VIDEOPRESS_POSTER_META_KEY !== $meta_key ) {
return;
}
if ( 'attachment' !== get_post_type( $object_id ) ) {
return;
}
wp_set_object_terms( (int) $object_id, $this->media_source_taxonomy::TERM_POSTER_GENERATION, $this->media_source_taxonomy->get_taxonomy_slug() );
}
/**
* Force Jetpack to see Web Stories as AMP.
*
* @since 1.2.0
*
* @param bool $is_amp_request Is the request supposed to return valid AMP content.
* @return bool Whether the current request is an AMP request.
*/
public function force_amp_request( bool $is_amp_request ): bool {
if ( ! $this->context->is_web_story() ) {
return (bool) $is_amp_request;
}
return true;
}
/**
* Format milliseconds into seconds.
*
* @since 1.7.2
*
* @param int $milliseconds Milliseconds to converted to minutes and seconds.
*/
protected function format_milliseconds( int $milliseconds ): string {
$seconds = floor( $milliseconds / 1000 );
if ( $seconds >= 1 ) {
$minutes = floor( $seconds / 60 );
$seconds %= 60;
} else {
$seconds = 0;
$minutes = 0;
}
return sprintf( '%d:%02u', $minutes, $seconds );
}
}

View File

@@ -0,0 +1,119 @@
<?php
/**
* Class New_Relic
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use Google\Web_Stories\Context;
use Google\Web_Stories\Infrastructure\Conditional;
use Google\Web_Stories\Service_Base;
/**
* New Relic integration class.
*
* @since 1.10.0
*/
class New_Relic extends Service_Base implements Conditional {
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Single constructor.
*
* @param Context $context Context instance.
*/
public function __construct( Context $context ) {
$this->context = $context;
}
/**
* Runs on instantiation.
*
* @since 1.10.0
*/
public function register(): void {
$this->disable_autorum();
}
/**
* Get the action to use for registering the service.
*
* @since 1.10.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'template_redirect';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.10.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
// Run at the same time as the output buffering.
return PHP_INT_MIN;
}
/**
* Check whether the conditional object is currently needed.
*
* @since 1.10.0
*
* @return bool Whether the conditional object is needed.
*/
public static function is_needed(): bool {
return \function_exists( '\newrelic_disable_autorum' );
}
/**
* Disable the New Relic Browser agent on AMP responses.
*
* This prevents the New Relic from causing invalid AMP responses due the NREUM script it injects after the meta charset:
*
* https://docs.newrelic.com/docs/browser/new-relic-browser/troubleshooting/google-amp-validator-fails-due-3rd-party-script
*
* Sites with New Relic will need to specially configure New Relic for AMP:
* https://docs.newrelic.com/docs/browser/new-relic-browser/installation/monitor-amp-pages-new-relic-browser
*
* @since 1.10.0
*/
public function disable_autorum(): void {
if ( ! $this->context->is_web_story() ) {
return;
}
\newrelic_disable_autorum();
}
}

View File

@@ -0,0 +1,132 @@
<?php
/**
* Class NextGen_Gallery
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\Story_Post_Type;
/**
* Class NextGen_Gallery.
*/
class NextGen_Gallery extends Service_Base {
/**
* Initializes all hooks.
*
* @since 1.2.0
*/
public function register(): void {
add_filter( 'run_ngg_resource_manager', [ $this, 'filter_run_ngg_resource_manager' ], PHP_INT_MAX );
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.6.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return -2;
}
/**
* Filters NextGEN Gallery's resource manager behavior.
*
* Disables output buffering for Web Stories.
*
* @since 1.2.0
*
* @see https://github.com/imagely/nextgen-gallery/blob/9736cc05e63b6b4cceb10b8a9a1de276f5c1ad4b/non_pope/class.photocrati_resource_manager.php
*
* @param bool|mixed $valid_request Whether NextGEN Gallery's output buffer should run.
* @return bool|mixed Whether the output buffer should run.
*/
public function filter_run_ngg_resource_manager( $valid_request ) {
$request_uri_path = $this->get_request_uri_path();
if (
// Plain permalinks.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
! empty( $_GET[ Story_Post_Type::POST_TYPE_SLUG ] ) ||
// Pretty permalinks.
(
$request_uri_path &&
preg_match(
'#/' . preg_quote( Story_Post_Type::REWRITE_SLUG, '#' ) . '/.*?$#',
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$request_uri_path
)
)
) {
return false;
}
return $valid_request;
}
/**
* Returns the current request path.
*
* @since 1.15.0
*
* @return string|null Request URI path on success, null on failure.
*/
private function get_request_uri_path(): ?string {
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return null;
}
if ( ! \is_string( $_SERVER['REQUEST_URI'] ) ) {
return null;
}
/**
* Request URI.
*
* @var string $request_uri
*/
$request_uri = $_SERVER['REQUEST_URI'];
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
/**
* Request URI path.
*
* @var string|null|false $path
*/
$path = wp_parse_url( $request_uri, PHP_URL_PATH );
if ( ! $path ) {
return null;
}
return $path;
}
}

View File

@@ -0,0 +1,65 @@
<?php
/**
* Class Plugin_Status
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2023 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
/**
* Class Plugin_Status.
*/
class Plugin_Status {
/**
* Array of arrays of plugin data, keyed by plugin file name.
*
* @see get_plugin_data()
*
* @var array<string, array<string, string|bool>>
*/
protected array $plugins = [];
/**
* Constructor.
*/
public function __construct() {
if ( ! \function_exists( '\get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$this->plugins = get_plugins();
}
/**
* Retrieves all plugin files with plugin data.
*
* @since 1.30.0
*
* @return array<string, array<string, string|bool>>
*/
public function get_plugins(): array {
return $this->plugins;
}
}

View File

@@ -0,0 +1,61 @@
<?php
/**
* Class ShortPixel.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use Google\Web_Stories\Service_Base;
/**
* Class ShortPixel
*/
class ShortPixel extends Service_Base {
/**
* Runs on instantiation.
*
* @since 1.23.0
*/
public function register(): void {
add_filter( 'shortpixel_image_urls', [ $this, 'image_urls' ] );
}
/**
* Ensures page template urls bypass optimisation.
*
* @since 1.23.0
*
* @param string[] $urls Urls that will be sent to optimisation.
* @return string[] The filtered Urls.
*/
public function image_urls( array $urls ): array {
return array_filter(
$urls,
static fn( $url ) => ! str_contains( $url, 'web-stories-page-template' )
);
}
}

View File

@@ -0,0 +1,274 @@
<?php
/**
* Class Site_Kit
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
use Google\Web_Stories\Analytics;
use Google\Web_Stories\Context;
use Google\Web_Stories\Service_Base;
/**
* Class Site_Kit.
*
* @phpstan-type GtagOpt array{
* vars: array{
* gtag_id?: string
* },
* triggers?: array<string, mixed>
* }
*/
class Site_Kit extends Service_Base {
/**
* Analytics instance.
*
* @var Analytics Analytics instance.
*/
protected Analytics $analytics;
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Plugin_Status instance.
*
* @var Plugin_Status Plugin_Status instance.
*/
private Plugin_Status $plugin_status;
/**
* Constructor.
*
* @param Analytics $analytics Analytics instance.
* @param Context $context Context instance.
* @param Plugin_Status $plugin_status Plugin_Status instance.
*/
public function __construct( Analytics $analytics, Context $context, Plugin_Status $plugin_status ) {
$this->analytics = $analytics;
$this->context = $context;
$this->plugin_status = $plugin_status;
}
/**
* Initializes all hooks.
*
* @since 1.2.0
*/
public function register(): void {
add_filter( 'googlesitekit_amp_gtag_opt', [ $this, 'filter_site_kit_gtag_opt' ] );
if ( $this->is_analytics_module_active() ) {
remove_action( 'web_stories_print_analytics', [ $this->analytics, 'print_analytics_tag' ] );
}
}
/**
* Filters Site Kit's Google Analytics configuration.
*
* @since 1.2.0
*
* @param array|mixed $gtag_opt Array of gtag configuration options.
* @return array|mixed Modified configuration options.
*
* @phpstan-param GtagOpt $gtag_opt
*/
public function filter_site_kit_gtag_opt( $gtag_opt ) {
if (
! \is_array( $gtag_opt ) ||
! isset( $gtag_opt['vars']['gtag_id'] ) ||
! $this->context->is_web_story()
) {
return $gtag_opt;
}
$default_config = $this->analytics->get_default_configuration( $gtag_opt['vars']['gtag_id'] );
$default_config['triggers'] = $default_config['triggers'] ?? [];
$gtag_opt['triggers'] ??= [];
$gtag_opt['triggers'] = array_merge( $default_config['triggers'], $gtag_opt['triggers'] );
return $gtag_opt;
}
/**
* Returns the Site Kit plugin status.
*
* @since 1.2.0
*
* @return array{installed: bool, active: bool, analyticsActive: bool, adsenseActive: bool, analyticsLink: string, adsenseLink: string} Plugin status.
*/
public function get_plugin_status(): array {
$is_installed = \array_key_exists( 'google-site-kit/google-site-kit.php', $this->plugin_status->get_plugins() );
$is_active = $this->is_plugin_active();
$is_analytics_active = $this->is_analytics_module_active();
$is_adsense_active = $this->is_adsense_module_active();
$analytics_link = __( 'https://wordpress.org/plugins/google-site-kit/', 'web-stories' );
$adsense_link = __( 'https://wordpress.org/plugins/google-site-kit/', 'web-stories' );
$dashboard = admin_url( 'admin.php?page=googlesitekit-dashboard' );
$settings = admin_url( 'admin.php?page=googlesitekit-settings' );
if ( $is_active ) {
$dashboard_capability = current_user_can( 'googlesitekit_view_dashboard' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
$settings_capability = current_user_can( 'googlesitekit_manage_options' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
// If analytics is active and current user can view dashboard.
if ( $is_analytics_active && $dashboard_capability ) {
$analytics_link = $dashboard;
} elseif ( $settings_capability ) {
$analytics_link = $settings;
} elseif ( $dashboard_capability ) {
$analytics_link = $dashboard;
}
// If adsense is active and current user can view dashboard.
if ( $is_adsense_active && $dashboard_capability ) {
$adsense_link = $dashboard;
} elseif ( $settings_capability ) {
$adsense_link = $settings;
} elseif ( $dashboard_capability ) {
$adsense_link = $dashboard;
}
} elseif ( $is_installed ) {
if ( current_user_can( 'activate_plugin', 'google-site-kit/google-site-kit.php' ) ) {
$analytics_link = admin_url( 'plugins.php' );
$adsense_link = $analytics_link;
}
} elseif ( current_user_can( 'install_plugins' ) ) {
$analytics_link = admin_url(
add_query_arg(
[
's' => rawurlencode( __( 'Site Kit by Google', 'web-stories' ) ),
'tab' => 'search',
],
'plugin-install.php'
)
);
$adsense_link = $analytics_link;
}
return [
'installed' => $is_active || $is_installed,
'active' => $is_active,
'analyticsActive' => $is_analytics_active,
'adsenseActive' => $is_adsense_active,
'analyticsLink' => $analytics_link,
'adsenseLink' => $adsense_link,
];
}
/**
* Determines whether Site Kit is active.
*
* @since 1.2.0
*
* @return bool Whether Site Kit is active.
*/
protected function is_plugin_active(): bool {
return \defined( 'GOOGLESITEKIT_VERSION' );
}
/**
* Determines whether the built-in adsense module in Site Kit is active.
*
* @since 1.8.0
*
* @return bool Whether Site Kit's analytics module is active.
*/
protected function is_adsense_module_active(): bool {
$adsense_module_active = \in_array( 'adsense', $this->get_site_kit_active_modules_option(), true );
$adsense_options = (array) get_option( 'googlesitekit_adsense_settings' );
$adsense_options_client_id = ! empty( $adsense_options['clientID'] );
$adsense_options_use_snippet = ! empty( $adsense_options['useSnippet'] );
$adsense_web_stories_ad_unit = ! empty( $adsense_options['webStoriesAdUnit'] );
return $adsense_module_active && $adsense_options_use_snippet && $adsense_web_stories_ad_unit && $adsense_options_client_id;
}
/**
* Determines whether the built-in Analytics module in Site Kit is active.
*
* @since 1.2.0
*
* @return bool Whether Site Kit's analytics module is active.
*/
protected function is_analytics_module_active(): bool {
$analytics_module_active = \in_array( 'analytics', $this->get_site_kit_active_modules_option(), true );
$analytics_options = (array) get_option( 'googlesitekit_analytics_settings' );
$analytics_use_snippet = ! empty( $analytics_options['useSnippet'] );
return $analytics_module_active && $analytics_use_snippet;
}
/**
* Gets the option containing the active Site Kit modules.
*
* Checks two options as it was renamed at some point in Site Kit.
*
* Bails early if the Site Kit plugin itself is not active.
*
* @since 1.2.0
*
* @see \Google\Site_Kit\Core\Modules\Modules::get_active_modules_option
*
* @return string[] List of active module slugs.
*/
protected function get_site_kit_active_modules_option(): array {
if ( ! $this->is_plugin_active() ) {
return [];
}
/**
* Option value.
*
* @var string[]|false $option
*/
$option = get_option( 'googlesitekit_active_modules' );
if ( \is_array( $option ) ) {
return $option;
}
/**
* Legacy option value.
*
* @var string[]|false $legacy_option
*/
$legacy_option = get_option( 'googlesitekit-active-modules' );
if ( \is_array( $legacy_option ) ) {
return $legacy_option;
}
return [];
}
}

View File

@@ -0,0 +1,110 @@
<?php
/**
* Class WooCommerce
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Integrations;
/**
* Class WooCommerce.
*/
class WooCommerce {
/**
* Main plugin file.
*/
protected const PLUGIN = 'woocommerce/woocommerce.php';
/**
* Plugin_Status instance.
*
* @var Plugin_Status Plugin_Status instance.
*/
private Plugin_Status $plugin_status;
/**
* Constructor.
*
* @param Plugin_Status $plugin_status Plugin_Status instance.
*/
public function __construct( Plugin_Status $plugin_status ) {
$this->plugin_status = $plugin_status;
}
/**
* Returns the WooCommerce plugin status.
*
* @since 1.21.0
*
* @return array{installed: bool, active: bool, canManage: bool, link: string} Plugin status.
*/
public function get_plugin_status(): array {
$is_installed = \array_key_exists( self::PLUGIN, $this->plugin_status->get_plugins() );
$is_active = $this->is_plugin_active();
$can_manage = false;
$link = '';
if ( $is_active ) {
$can_manage = current_user_can( 'manage_woocommerce' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
if ( $can_manage ) {
$link = admin_url( 'admin.php?page=wc-admin' );
}
} elseif ( $is_installed ) {
if ( current_user_can( 'activate_plugin', self::PLUGIN ) ) {
$link = admin_url( 'plugins.php' );
}
} elseif ( current_user_can( 'install_plugins' ) ) {
$link = admin_url(
add_query_arg(
[
's' => rawurlencode( __( 'WooCommerce', 'web-stories' ) ),
'tab' => 'search',
],
'plugin-install.php'
)
);
} else {
$link = __( 'https://wordpress.org/plugins/woocommerce/', 'web-stories' );
}
return [
'installed' => $is_active || $is_installed,
'active' => $is_active,
'canManage' => $can_manage,
'link' => $link,
];
}
/**
* Determines whether WooCommerce is active.
*
* @since 1.21.0
*
* @return bool Whether WooCommerce is active.
*/
protected function is_plugin_active(): bool {
return class_exists( 'WooCommerce', false );
}
}

View File

@@ -0,0 +1,40 @@
<?php
/**
* Field Interface.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Interfaces;
/**
* Interface Field.
*/
interface Field {
/**
* Whether to display the field.
*
* @since 1.5.0
*/
public function show(): bool;
/**
* Label for current field.
*
* @since 1.5.0
*/
public function label(): string;
/**
* Whether the field is hidden.
*
* @since 1.5.0
*/
public function hidden(): bool;
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* Field State Interface
*
* Renderer fields will change state based on view types.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Interfaces;
/**
* Interface FieldState.
*/
interface FieldState {
/**
* Get title field along with its state for
* current view type.
*
* @since 1.5.0
*
* @return Field
*/
public function title();
/**
* Get excerpt field along with its state for
* current view type.
*
* @since 1.5.0
*
* @return Field
*/
public function excerpt();
/**
* Get image alignment field along with its state for
* current view type.
*
* @since 1.5.0
*
* @return Field
*/
public function image_alignment();
/**
* Get author field along with its state for
* current view type.
*
* @since 1.5.0
*
* @return Field
*/
public function author();
/**
* Get date field along with its state for
* current view type.
*
* @since 1.5.0
*
* @return Field
*/
public function date();
/**
* Get archive link field along with its state for
* current view type.
*
* @since 1.5.0
*
* @return Field
*/
public function archive_link();
/**
* Get sharp corner field along with its state for
* current view type.
*
* @since 1.5.0
*
* @return Field
*/
public function sharp_corners();
}

View File

@@ -0,0 +1,30 @@
<?php
/**
* Field State Factory Interface
*
* Factory for field state types.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Interfaces;
/**
* Interface FieldState.
*/
interface FieldStateFactory {
/**
* Get field state by title.
*
* @since 1.5.0
*
* @return FieldState
*/
public function get_field();
}

View File

@@ -0,0 +1,42 @@
<?php
/**
* Migration Interface.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Interfaces;
/**
* Interface Migration
*/
interface Migration {
/**
* Migrate
*
* @since 1.7.0
*/
public function migrate(): void;
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* Interface Product_Query
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Interfaces;
use Google\Web_Stories\Shopping\Product;
use WP_Error;
/**
* Interface Product_Query.
*/
interface Product_Query {
/**
* Get products by search term.
*
* @since 1.21.0
*
* @param string $search_term Search term.
* @param int $page Number of page for paginated requests.
* @param int $per_page Number of products to be fetched.
* @param string $orderby Sort collection by product attribute.
* @param string $order Order sort attribute ascending or descending.
* @return array{products: array<Product>, has_next_page: bool}|WP_Error
*/
public function get_search( string $search_term, int $page = 1, int $per_page = 100, string $orderby = 'date', string $order = 'desc' );
}

View File

@@ -0,0 +1,64 @@
<?php
/**
* Renderer Interface.
*
* Stories renderers should conform to this interface,
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Interfaces;
/**
* Interface Renderer.
*/
interface Renderer {
/**
* Initial actions to setup the renderer like,
* adding hooks and setting up states.
*
* @since 1.5.0
*/
public function init(): void;
/**
* Render the markup for story.
*
* @since 1.5.0
*
* @param array<string,mixed> $args Array of rendering related arguments.
* @return string Rendering markup.
*/
public function render( array $args = [] ): string;
/**
* Render a single story markup.
*
* @since 1.5.0
*
* @return mixed
*/
public function render_single_story_content();
}

View File

@@ -0,0 +1,898 @@
<?php
/**
* Class KSES.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\HasRequirements;
/**
* KSES class.
*
* Provides KSES utility methods to override the ones from core.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type PostData array{
* post_parent: int|string|null,
* post_type: string,
* post_content?: string,
* post_content_filtered?: string
* }
*/
class KSES extends Service_Base implements HasRequirements {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Page_Template_Post_Type instance.
*
* @var Page_Template_Post_Type Page_Template_Post_Type instance.
*/
private Page_Template_Post_Type $page_template_post_type;
/**
* KSES constructor.
*
* @since 1.12.0
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Page_Template_Post_Type $page_template_post_type Page_Template_Post_Type instance.
*/
public function __construct(
Story_Post_Type $story_post_type,
Page_Template_Post_Type $page_template_post_type
) {
$this->story_post_type = $story_post_type;
$this->page_template_post_type = $page_template_post_type;
}
/**
* Initializes KSES filters for stories.
*
* @since 1.0.0
*/
public function register(): void {
add_filter( 'wp_insert_post_data', [ $this, 'filter_insert_post_data' ], 10, 3 );
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type', 'page_template_post_type' ];
}
/**
* Filters slashed post data just before it is inserted into the database.
*
* Used to run story HTML markup through KSES on our own, but with some filters applied
* that should only affect the web-story post type.
*
* This allows storing full AMP HTML documents in post_content for stories, which require
* more allowed HTML tags and a patched version of {@see safecss_filter_attr}.
*
* @since 1.8.0
*
* @param mixed $data An array of slashed, sanitized, and processed post data.
* @param mixed $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
* @param mixed $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as
* originally passed to wp_insert_post().
* @return array<string,mixed>|mixed Filtered post data.
*
* @phpstan-param PostData $data
* @phpstan-param PostData $unsanitized_postarr
*
* @template T
*
* @phpstan-return ($data is array<T> ? array<T> : mixed)
*/
public function filter_insert_post_data( $data, $postarr, $unsanitized_postarr ) {
if ( current_user_can( 'unfiltered_html' ) ) {
return $data;
}
if ( ! \is_array( $data ) || ! \is_array( $postarr ) || ! \is_array( $unsanitized_postarr ) ) {
return $data;
}
if ( ! $this->is_allowed_post_type( $data['post_type'], $data['post_parent'] ) ) {
return $data;
}
if ( isset( $unsanitized_postarr['post_content_filtered'] ) ) {
$data['post_content_filtered'] = $this->filter_story_data( $unsanitized_postarr['post_content_filtered'] );
}
if ( isset( $unsanitized_postarr['post_content'] ) ) {
add_filter( 'safe_style_css', [ $this, 'filter_safe_style_css' ] );
add_filter( 'wp_kses_allowed_html', [ $this, 'filter_kses_allowed_html' ] );
$unsanitized_postarr['post_content'] = $this->filter_content_save_pre_before_kses( $unsanitized_postarr['post_content'] );
$data['post_content'] = wp_filter_post_kses( $unsanitized_postarr['post_content'] );
$data['post_content'] = $this->filter_content_save_pre_after_kses( $data['post_content'] );
remove_filter( 'safe_style_css', [ $this, 'filter_safe_style_css' ] );
remove_filter( 'wp_kses_allowed_html', [ $this, 'filter_kses_allowed_html' ] );
}
return $data;
}
/**
* Filters list of allowed CSS attributes.
*
* @since 1.0.0
*
* @param string[]|mixed $attr Array of allowed CSS attributes.
* @return string[]|mixed Filtered list of CSS attributes.
*
* @template T
*
* @phpstan-return ($attr is array<T> ? array<T> : mixed)
*/
public function filter_safe_style_css( $attr ) {
if ( ! \is_array( $attr ) ) {
return $attr;
}
$additional = [
'display',
'opacity',
'position',
'top',
'left',
'transform',
'white-space',
'clip-path',
'-webkit-clip-path',
'pointer-events',
'will-change',
'--initial-opacity',
'--initial-transform',
];
array_push( $attr, ...$additional );
return $attr;
}
/**
* Filters an inline style attribute and removes disallowed rules.
*
* This is equivalent to the WordPress core function of the same name,
* except that this does not remove CSS with parentheses in it.
*
* A few more allowed attributes are added via the safe_style_css filter.
*
* @SuppressWarnings(PHPMD)
*
* @since 1.0.0
*
* @see safecss_filter_attr()
*
* @param string $css A string of CSS rules.
* @return string Filtered string of CSS rules.
*/
public function safecss_filter_attr( string $css ): string { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
$css = wp_kses_no_null( $css );
$css = str_replace( [ "\n", "\r", "\t" ], '', $css );
$allowed_protocols = wp_allowed_protocols();
$css_array = explode( ';', trim( $css ) );
/** This filter is documented in wp-includes/kses.php */
$allowed_attr = apply_filters(
'safe_style_css',
[
'background',
'background-color',
'background-image',
'background-position',
'background-size',
'background-attachment',
'background-blend-mode',
'border',
'border-radius',
'border-width',
'border-color',
'border-style',
'border-right',
'border-right-color',
'border-right-style',
'border-right-width',
'border-bottom',
'border-bottom-color',
'border-bottom-style',
'border-bottom-width',
'border-left',
'border-left-color',
'border-left-style',
'border-left-width',
'border-top',
'border-top-color',
'border-top-style',
'border-top-width',
'border-spacing',
'border-collapse',
'caption-side',
'columns',
'column-count',
'column-fill',
'column-gap',
'column-rule',
'column-span',
'column-width',
'color',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'letter-spacing',
'line-height',
'text-align',
'text-decoration',
'text-indent',
'text-transform',
'height',
'min-height',
'max-height',
'width',
'min-width',
'max-width',
'margin',
'margin-right',
'margin-bottom',
'margin-left',
'margin-top',
'padding',
'padding-right',
'padding-bottom',
'padding-left',
'padding-top',
'flex',
'flex-basis',
'flex-direction',
'flex-flow',
'flex-grow',
'flex-shrink',
'grid-template-columns',
'grid-auto-columns',
'grid-column-start',
'grid-column-end',
'grid-column-gap',
'grid-template-rows',
'grid-auto-rows',
'grid-row-start',
'grid-row-end',
'grid-row-gap',
'grid-gap',
'justify-content',
'justify-items',
'justify-self',
'align-content',
'align-items',
'align-self',
'clear',
'cursor',
'direction',
'float',
'overflow',
'vertical-align',
'list-style-type',
'z-index',
]
);
/*
* CSS attributes that accept URL data types.
*
* This is in accordance to the CSS spec and unrelated to
* the sub-set of supported attributes above.
*
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/url
*/
$css_url_data_types = [
'background',
'background-image',
'cursor',
'list-style',
'list-style-image',
'clip-path',
'-webkit-clip-path',
];
/*
* CSS attributes that accept gradient data types.
*
*/
$css_gradient_data_types = [
'background',
'background-image',
];
/*
* CSS attributes that accept color data types.
*
* This is in accordance to the CSS spec and unrelated to
* the sub-set of supported attributes above.
*
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
*/
$css_color_data_types = [
'color',
'background',
'background-color',
'border-color',
'box-shadow',
'outline',
'outline-color',
'text-shadow',
];
if ( empty( $allowed_attr ) ) {
return $css;
}
$css = '';
foreach ( $css_array as $css_item ) {
if ( '' === $css_item ) {
continue;
}
$css_item = trim( $css_item );
$css_test_string = $css_item;
$found = false;
$url_attr = false;
$gradient_attr = false;
$color_attr = false;
$transform_attr = false;
$parts = explode( ':', $css_item, 2 );
if ( ! str_contains( $css_item, ':' ) ) {
$found = true;
} else {
$css_selector = trim( $parts[0] );
if ( \in_array( $css_selector, $allowed_attr, true ) ) {
$found = true;
$url_attr = \in_array( $css_selector, $css_url_data_types, true );
$gradient_attr = \in_array( $css_selector, $css_gradient_data_types, true );
$color_attr = \in_array( $css_selector, $css_color_data_types, true );
// --initial-transform is a special custom property used by the story editor.
$transform_attr = 'transform' === $css_selector || '--initial-transform' === $css_selector;
}
}
if ( $found && $url_attr ) {
$url_matches = [];
// Simplified: matches the sequence `url(*)`.
preg_match_all( '/url\([^)]+\)/', $parts[1], $url_matches );
foreach ( $url_matches[0] as $url_match ) {
$url_pieces = [];
// Clean up the URL from each of the matches above.
preg_match( '/^url\(\s*([\'\"]?)(.*)(\g1)\s*\)$/', $url_match, $url_pieces );
if ( empty( $url_pieces[2] ) ) {
$found = false;
break;
}
$url = trim( $url_pieces[2] );
if ( empty( $url ) || wp_kses_bad_protocol( $url, $allowed_protocols ) !== $url ) {
$found = false;
break;
}
// Remove the whole `url(*)` bit that was matched above from the CSS.
$css_test_string = str_replace( $url_match, '', $css_test_string );
}
}
if ( $found && $gradient_attr ) {
$css_value = trim( $parts[1] );
if ( preg_match( '/^(repeating-)?(linear|radial|conic)-gradient\(([^()]|rgb[a]?\([^()]*\))*\)$/', $css_value ) ) {
// Remove the whole `gradient` bit that was matched above from the CSS.
$css_test_string = str_replace( $css_value, '', $css_test_string );
}
}
if ( $found && $color_attr ) {
$color_matches = [];
// Simplified: matches the sequence `rgb(*)` and `rgba(*)`.
preg_match_all( '/rgba?\([^)]+\)/', $parts[1], $color_matches );
foreach ( $color_matches[0] as $color_match ) {
$color_pieces = [];
// Clean up the color from each of the matches above.
preg_match( '/^rgba?\([^)]*\)$/', $color_match, $color_pieces );
// Remove the whole `rgb(*)` / `rgba(*) bit that was matched above from the CSS.
$css_test_string = str_replace( $color_match, '', $css_test_string );
}
}
if ( $found && $transform_attr ) {
$css_value = trim( $parts[1] );
if ( preg_match( '/^((matrix|matrix3d|perspective|rotate|rotate3d|rotateX|rotateY|rotateZ|translate|translate3d|translateX|translatY|translatZ|scale|scale3d|scalX|scaleY|scaleZ|skew|skewX|skeY)\(([^()])*\) ?)+$/', $css_value ) ) {
// Remove the whole `gradient` bit that was matched above from the CSS.
$css_test_string = str_replace( $css_value, '', $css_test_string );
}
}
if ( $found ) {
// Allow CSS calc().
$css_test_string = (string) preg_replace( '/calc\(((?:\([^()]*\)?|[^()])*)\)/', '', $css_test_string );
// Allow CSS var().
$css_test_string = (string) preg_replace( '/\(?var\(--[a-zA-Z0-9_-]*\)/', '', $css_test_string );
// Check for any CSS containing \ ( & } = or comments,
// except for url(), calc(), or var() usage checked above.
$allow_css = ! preg_match( '%[\\\(&=}]|/\*%', $css_test_string );
/** This filter is documented in wp-includes/kses.php */
$allow_css = apply_filters( 'safecss_filter_attr_allow_css', $allow_css, $css_test_string );
// Only add the CSS part if it passes the regex check.
if ( $allow_css ) {
if ( '' !== $css ) {
$css .= ';';
}
$css .= $css_item;
}
}
}
return $css;
}
/**
* Filter the allowed tags for KSES to allow for complete amp-story document markup.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.0.0
*
* @param array<string, array<string,bool>>|mixed $allowed_tags Allowed tags.
* @return array<string, array<string,bool>>|mixed Allowed tags.
*
* @template T
*
* @phpstan-return ($allowed_tags is array<T> ? array<T> : mixed)
*/
public function filter_kses_allowed_html( $allowed_tags ) {
if ( ! \is_array( $allowed_tags ) ) {
return $allowed_tags;
}
$story_components = [
'html' => [
'amp' => true,
'lang' => true,
],
'head' => [],
'body' => [],
'meta' => [
'name' => true,
'content' => true,
'charset' => true,
],
'script' => [
'async' => true,
'src' => true,
'custom-element' => true,
'type' => true,
],
'noscript' => [],
'link' => [
'href' => true,
'rel' => true,
],
'style' => [
'type' => true,
'amp-boilerplate' => true,
'amp-custom' => true,
],
'amp-story' => [
'background-audio' => true,
'live-story' => true,
'live-story-disabled' => true,
'poster-landscape-src' => true,
'poster-portrait-src' => true,
'poster-square-src' => true,
'publisher' => true,
'publisher-logo-src' => true,
'standalone' => true,
'supports-landscape' => true,
'title' => true,
],
'amp-story-captions' => [
'height' => true,
'style-preset' => true,
],
'amp-story-shopping-attachment' => [
'cta-text' => true,
'theme' => true,
'src' => true,
],
'amp-story-shopping-config' => [
'src' => true,
],
'amp-story-shopping-tag' => [],
'amp-story-page' => [
'auto-advance-after' => true,
'background-audio' => true,
'id' => true,
],
'amp-story-page-attachment' => [
'href' => true,
'theme' => true,
],
'amp-story-page-outlink' => [
'cta-image' => true,
'theme' => true,
'cta-accent-color' => true,
'cta-accent-element' => true,
],
'amp-story-grid-layer' => [
'aspect-ratio' => true,
'position' => true,
'template' => true,
],
'amp-story-cta-layer' => [],
'amp-story-animation' => [
'trigger' => true,
],
'amp-img' => [
'alt' => true,
'attribution' => true,
'data-amp-bind-alt' => true,
'data-amp-bind-attribution' => true,
'data-amp-bind-src' => true,
'data-amp-bind-srcset' => true,
'disable-inline-width' => true,
'lightbox' => true,
'lightbox-thumbnail-id' => true,
'media' => true,
'noloading' => true,
'object-fit' => true,
'object-position' => true,
'placeholder' => true,
'sizes' => true,
'src' => true,
'srcset' => true,
],
'amp-video' => [
'album' => true,
'alt' => true,
'artist' => true,
'artwork' => true,
'attribution' => true,
'autoplay' => true,
'captions-id' => true,
'controls' => true,
'controlslist' => true,
'crossorigin' => true,
'data-amp-bind-album' => true,
'data-amp-bind-alt' => true,
'data-amp-bind-artist' => true,
'data-amp-bind-artwork' => true,
'data-amp-bind-attribution' => true,
'data-amp-bind-controls' => true,
'data-amp-bind-controlslist' => true,
'data-amp-bind-loop' => true,
'data-amp-bind-poster' => true,
'data-amp-bind-preload' => true,
'data-amp-bind-src' => true,
'data-amp-bind-title' => true,
'disableremoteplayback' => true,
'dock' => true,
'lightbox' => true,
'lightbox-thumbnail-id' => true,
'loop' => true,
'media' => true,
'muted' => true,
'noaudio' => true,
'noloading' => true,
'object-fit' => true,
'object-position' => true,
'placeholder' => true,
'poster' => true,
'preload' => true,
'rotate-to-fullscreen' => true,
'src' => true,
],
'source' => [
'type' => true,
'src' => true,
],
'img' => [
'alt' => true,
'attribution' => true,
'border' => true,
'decoding' => true,
'height' => true,
'importance' => true,
'intrinsicsize' => true,
'ismap' => true,
'loading' => true,
'longdesc' => true,
'sizes' => true,
'src' => true,
'srcset' => true,
'srcwidth' => true,
'width' => true,
],
'svg' => [
'width' => true,
'height' => true,
'viewbox' => true,
'fill' => true,
'xmlns' => true,
],
'clippath' => [
'transform' => true,
'clippathunits' => true,
'path' => true,
],
'defs' => [],
'feblend' => [
'in' => true,
'in2' => true,
'result' => true,
],
'fecolormatrix' => [
'in' => true,
'values' => true,
],
'feflood' => [
'flood-opacity' => true,
'result' => true,
],
'fegaussianblur' => [
'stddeviation' => true,
],
'feoffset' => [],
'filter' => [
'id' => true,
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'filterunits' => true,
'color-interpolation-filters' => true,
],
'g' => [
'filter' => true,
'opacity' => true,
],
'path' => [
'd' => true,
'fill-rule' => true,
'clip-rule' => true,
'fill' => true,
],
];
$allowed_tags = $this->array_merge_recursive_distinct( $allowed_tags, $story_components );
$allowed_tags = array_map( [ $this, 'add_global_attributes' ], $allowed_tags );
return $allowed_tags;
}
/**
* Temporarily renames the style attribute to data-temp-style in full story markup.
*
* @since 1.0.0
*
* @param string $post_content Post content.
* @return string Filtered post content.
*/
public function filter_content_save_pre_before_kses( string $post_content ): string {
return (string) preg_replace_callback(
'|(?P<before><\w+(?:-\w+)*\s[^>]*?)style=\\\"(?P<styles>[^"]*)\\\"(?P<after>([^>]+?)*>)|', // Extra slashes appear here because $post_content is pre-slashed..
static fn( $matches ) => $matches['before'] . sprintf( ' data-temp-style="%s" ', $matches['styles'] ) . $matches['after'],
$post_content
);
}
/**
* Renames data-temp-style back to style in full story markup.
*
* @since 1.0.0
*
* @param string $post_content Post content.
* @return string Filtered post content.
*/
public function filter_content_save_pre_after_kses( string $post_content ): string {
return (string) preg_replace_callback(
'/ data-temp-style=\\\"(?P<styles>[^"]*)\\\"/',
function ( $matches ) {
$styles = str_replace( '&quot;', '\"', $matches['styles'] );
return sprintf( ' style="%s"', esc_attr( $this->safecss_filter_attr( wp_kses_stripslashes( $styles ) ) ) );
},
$post_content
);
}
/**
* Checks whether the post type is correct and user has capability to edit it.
*
* @since 1.22.0
*
* @param string $post_type Post type slug.
* @param int|string|null $post_parent Parent post ID.
* @return bool Whether the user can edit the provided post type.
*/
private function is_allowed_post_type( string $post_type, $post_parent ): bool {
if ( $this->story_post_type->get_slug() === $post_type && $this->story_post_type->has_cap( 'edit_posts' ) ) {
return true;
}
if ( $this->page_template_post_type->get_slug() === $post_type && $this->page_template_post_type->has_cap( 'edit_posts' ) ) {
return true;
}
// For story autosaves.
if (
(
'revision' === $post_type &&
! empty( $post_parent ) &&
get_post_type( (int) $post_parent ) === $this->story_post_type->get_slug()
) &&
$this->story_post_type->has_cap( 'edit_posts' )
) {
return true;
}
return false;
}
/**
* Filters story data.
*
* Provides simple sanity check to ensure story data is valid JSON.
*
* @since 1.22.0
*
* @param string $story_data JSON-encoded story data.
* @return string Sanitized & slashed story data.
*/
private function filter_story_data( string $story_data ): string {
$decoded = json_decode( (string) wp_unslash( $story_data ), true );
return null === $decoded ? '' : wp_slash( (string) wp_json_encode( $decoded ) );
}
/**
* Recursively merge multiple arrays and ensure values are distinct.
*
* Based on information found in http://www.php.net/manual/en/function.array-merge-recursive.php
*
* @since 1.5.0
*
* @param array<int|string,mixed> ...$arrays [optional] Variable list of arrays to recursively merge.
* @return array<int|string,mixed> An array of values resulted from merging the arguments together.
*/
protected function array_merge_recursive_distinct( array ...$arrays ): array {
if ( \count( $arrays ) < 2 ) {
if ( [] === $arrays ) {
return $arrays;
}
return array_shift( $arrays );
}
$merged = array_shift( $arrays );
foreach ( $arrays as $array ) {
foreach ( $array as $key => $value ) {
if ( \is_array( $value ) && ( isset( $merged[ $key ] ) && \is_array( $merged[ $key ] ) ) ) {
$merged[ $key ] = $this->array_merge_recursive_distinct( $merged[ $key ], $value );
} else {
$merged[ $key ] = $value;
}
}
}
return $merged;
}
/**
* Helper function to add global attributes to a tag in the allowed HTML list.
*
* @since 1.0.0
*
* @see _wp_add_global_attributes
*
* @param array<string,bool> $value An array of attributes.
* @return array<string,bool> The array of attributes with global attributes added.
*/
protected function add_global_attributes( array $value ): array {
$global_attributes = [
'aria-describedby' => true,
'aria-details' => true,
'aria-label' => true,
'aria-labelledby' => true,
'aria-hidden' => true,
'class' => true,
'id' => true,
'style' => true,
'title' => true,
'role' => true,
'data-*' => true,
'animate-in' => true,
'animate-in-duration' => true,
'animate-in-delay' => true,
'animate-in-after' => true,
'animate-in-layout' => true,
'layout' => true,
];
return array_merge( $value, $global_attributes );
}
}

View File

@@ -0,0 +1,118 @@
<?php
/**
* Locale class.
*
* Locale-related functionality.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use DateTime;
use DateTimeZone;
/**
* Locale class.
*/
class Locale {
/**
* Returns locale settings for use on the client side.
*
* @since 1.1.0
*
* @return array<string,mixed> Locale settings.
*/
public function get_locale_settings(): array {
global $wp_locale;
/* translators: Date format, see https://www.php.net/manual/en/datetime.format.php */
$default_date_format = __( 'd/m/Y', 'web-stories' );
/* translators: Date format, see https://www.php.net/manual/en/datetime.format.php */
$default_time_format = __( 'g:i a', 'web-stories' );
/**
* Date format value.
*
* @var string $date_format
*/
$date_format = get_option( 'date_format', $default_date_format );
if ( empty( trim( $date_format ) ) ) {
$date_format = $default_date_format;
}
/**
* Time format value.
*
* @var string $time_format
*/
$time_format = get_option( 'time_format', $default_time_format );
if ( empty( trim( $time_format ) ) ) {
$time_format = $default_time_format;
}
/**
* Time zone string.
*
* @var string $timezone_string
*/
$timezone_string = get_option( 'timezone_string', 'UTC' );
$timezone_abbr = '';
if ( ! empty( $timezone_string ) ) {
$timezone_date = new DateTime( 'now', new DateTimeZone( $timezone_string ) );
$timezone_abbr = $timezone_date->format( 'T' );
}
/**
* Start of week value.
*
* @var int|string $start_of_week
*/
$start_of_week = get_option( 'start_of_week', 0 );
/**
* GMT Offset.
*
* @var int $gmt_offset
*/
$gmt_offset = get_option( 'gmt_offset', 0 );
return [
'locale' => str_replace( '_', '-', get_user_locale() ),
'dateFormat' => $date_format,
'timeFormat' => $time_format,
'gmtOffset' => (float) $gmt_offset,
'timezone' => $timezone_string,
'timezoneAbbr' => $timezone_abbr,
'months' => array_values( $wp_locale->month ),
'monthsShort' => array_values( $wp_locale->month_abbrev ),
'weekdays' => array_values( $wp_locale->weekday ),
'weekdaysShort' => array_values( $wp_locale->weekday_abbrev ),
'weekdaysInitials' => array_values( $wp_locale->weekday_initial ),
'weekStartsOn' => (int) $start_of_week,
];
}
}

View File

@@ -0,0 +1,117 @@
<?php
/**
* Class Image_Size
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Service_Base;
/**
* Class Base_Color
*/
class Base_Color extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* The base color meta key.
*/
public const BASE_COLOR_POST_META_KEY = 'web_stories_base_color';
/**
* Init.
*
* @since 1.15.0
*/
public function register(): void {
$this->register_meta();
add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] );
}
/**
* Register meta
*
* @since 1.15.0
*/
public function register_meta(): void {
register_meta(
'post',
self::BASE_COLOR_POST_META_KEY,
[
'type' => 'string',
'description' => __( 'Attachment base color', 'web-stories' ),
'show_in_rest' => [
'schema' => [
'type' => 'string',
'format' => 'hex-color',
],
],
'single' => true,
'object_subtype' => 'attachment',
]
);
}
/**
* Filters the attachment data prepared for JavaScript.
*
* @since 1.15.0
*
* @param array|mixed $response Array of prepared attachment data.
* @return array|mixed $response;
*
* @template T
*
* @phpstan-return ($response is array<T> ? array<T> : mixed)
*/
public function wp_prepare_attachment_for_js( $response ) {
if ( ! \is_array( $response ) ) {
return $response;
}
/**
* Attachment ID.
*
* @var int $post_id
*/
$post_id = $response['id'];
$response[ self::BASE_COLOR_POST_META_KEY ] = get_post_meta( $post_id, self::BASE_COLOR_POST_META_KEY, true );
return $response;
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::BASE_COLOR_POST_META_KEY );
}
}

View File

@@ -0,0 +1,116 @@
<?php
/**
* Class Blurhash
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Service_Base;
/**
* Class Blurhash
*/
class Blurhash extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* The blurhash meta key.
*/
public const BLURHASH_POST_META_KEY = 'web_stories_blurhash';
/**
* Init.
*
* @since 1.16.0
*/
public function register(): void {
$this->register_meta();
add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] );
}
/**
* Register meta
*
* @since 1.16.0
*/
public function register_meta(): void {
register_meta(
'post',
self::BLURHASH_POST_META_KEY,
[
'type' => 'string',
'description' => __( 'Attachment BlurHash', 'web-stories' ),
'show_in_rest' => [
'schema' => [
'type' => 'string',
],
],
'single' => true,
'object_subtype' => 'attachment',
]
);
}
/**
* Filters the attachment data prepared for JavaScript.
*
* @since 1.16.0
*
* @param array|mixed $response Array of prepared attachment data.
* @return array|mixed Response data.
*
* @template T
*
* @phpstan-return ($response is array<T> ? array<T> : mixed)
*/
public function wp_prepare_attachment_for_js( $response ) {
if ( ! \is_array( $response ) ) {
return $response;
}
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $response['id'];
$response[ self::BLURHASH_POST_META_KEY ] = get_post_meta( $post_id, self::BLURHASH_POST_META_KEY, true );
return $response;
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::BLURHASH_POST_META_KEY );
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* Class Cropping
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Service_Base;
/**
* Class Cropping
*/
class Cropping extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* The cropped video id post meta key.
*/
public const CROPPED_ID_POST_META_KEY = 'web_stories_cropped_origin_id';
/**
* Init.
*
* @since 1.26.0
*/
public function register(): void {
$this->register_meta();
add_action( 'delete_attachment', [ $this, 'delete_video' ] );
}
/**
* Register meta
*
* @since 1.26.0
*/
public function register_meta(): void {
register_meta(
'post',
self::CROPPED_ID_POST_META_KEY,
[
'sanitize_callback' => 'absint',
'type' => 'integer',
'description' => __( 'Parent ID if this is a cropped attachment', 'web-stories' ),
'show_in_rest' => true,
'default' => 0,
'single' => true,
'object_subtype' => 'attachment',
]
);
}
/**
* Deletes associated meta data when a video is deleted.
*
* @since 1.26.0
*
* @param int $attachment_id ID of the attachment to be deleted.
*/
public function delete_video( int $attachment_id ): void {
delete_metadata( 'post', 0, self::CROPPED_ID_POST_META_KEY, $attachment_id, true );
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::CROPPED_ID_POST_META_KEY );
}
}

View File

@@ -0,0 +1,173 @@
<?php
/**
* Class Image_Size
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media;
use Google\Web_Stories\Service_Base;
use WP_Post;
/**
* Class Image_Sizes
*/
class Image_Sizes extends Service_Base {
/**
* The image size for the poster-portrait-src.
*/
public const POSTER_PORTRAIT_IMAGE_SIZE = 'web-stories-poster-portrait';
/**
* The image dimensions for the poster-portrait-src.
*/
public const POSTER_PORTRAIT_IMAGE_DIMENSIONS = [ 640, 853 ];
/**
* Name of size used in media library.
*/
public const STORY_THUMBNAIL_IMAGE_SIZE = 'web-stories-thumbnail';
/**
* The image dimensions for media library thumbnails.
*/
public const STORY_THUMBNAIL_IMAGE_DIMENSIONS = [ 150, 9999 ];
/**
* The image size for the publisher logo.
*/
public const PUBLISHER_LOGO_IMAGE_SIZE = 'web-stories-publisher-logo';
/**
* The image dimensions for the publisher logo.
*/
public const PUBLISHER_LOGO_IMAGE_DIMENSIONS = [ 96, 96 ];
/**
* Init.
*
* @since 1.0.0
*/
public function register(): void {
$this->add_image_sizes();
add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ], 10, 2 );
}
/**
* Filters the attachment data prepared for JavaScript.
*
* @since 1.0.0
*
* @param array|mixed $response Array of prepared attachment data.
* @param WP_Post $attachment Attachment object.
* @return array|mixed $response;
*
* @template T
*
* @phpstan-return ($response is array<T> ? array<T> : mixed)
*/
public function wp_prepare_attachment_for_js( $response, WP_Post $attachment ) {
if ( ! \is_array( $response ) ) {
return $response;
}
// See https://github.com/WordPress/wordpress-develop/blob/d28766f8f2ecf2be02c2520cdf0cc3b51deb9e1b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php#L753-L791 .
$response['media_details'] = wp_get_attachment_metadata( $attachment->ID );
// Ensure empty details is an empty object.
if ( empty( $response['media_details'] ) ) {
$response['media_details'] = [];
} elseif ( ! empty( $response['media_details']['sizes'] ) ) {
foreach ( $response['media_details']['sizes'] as $size => &$size_data ) {
if ( isset( $size_data['mime-type'] ) ) {
$size_data['mime_type'] = $size_data['mime-type'];
unset( $size_data['mime-type'] );
}
// Use the same method image_downsize() does.
$image = wp_get_attachment_image_src( $attachment->ID, $size );
if ( ! $image ) {
continue;
}
[ $image_src ] = $image;
$size_data['source_url'] = $image_src;
}
$img_src = wp_get_attachment_image_src( $attachment->ID, 'full' );
if ( $img_src ) {
[ $src, $width, $height ] = $img_src;
$response['media_details']['sizes']['full'] = [
'file' => wp_basename( $src ),
'width' => $width,
'height' => $height,
'mime_type' => $attachment->post_mime_type,
'source_url' => $src,
];
}
} else {
$response['media_details']['sizes'] = [];
}
return $response;
}
/**
* Add image sizes.
*
* @since 1.10.0
*
* @link https://amp.dev/documentation/components/amp-story/#poster-guidelines-for-poster-portrait-src-poster-landscape-src-and-poster-square-src.
*/
protected function add_image_sizes(): void {
// Used for amp-story[poster-portrait-src]: The story poster in portrait format (3x4 aspect ratio).
add_image_size(
self::POSTER_PORTRAIT_IMAGE_SIZE,
self::POSTER_PORTRAIT_IMAGE_DIMENSIONS[0],
self::POSTER_PORTRAIT_IMAGE_DIMENSIONS[1],
true
);
// As per https://amp.dev/documentation/components/amp-story/#publisher-logo-src-guidelines.
add_image_size(
self::PUBLISHER_LOGO_IMAGE_SIZE,
self::PUBLISHER_LOGO_IMAGE_DIMENSIONS[0],
self::PUBLISHER_LOGO_IMAGE_DIMENSIONS[1],
true
);
// Used in the editor.
add_image_size(
self::STORY_THUMBNAIL_IMAGE_SIZE,
self::STORY_THUMBNAIL_IMAGE_DIMENSIONS[0],
self::STORY_THUMBNAIL_IMAGE_DIMENSIONS[1],
false
);
}
}

View File

@@ -0,0 +1,403 @@
<?php
/**
* Class Media_Source_Taxonomy
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media;
use Google\Web_Stories\Context;
use Google\Web_Stories\REST_API\Stories_Terms_Controller;
use Google\Web_Stories\Taxonomy\Taxonomy_Base;
use ReflectionClass;
use WP_Post;
use WP_Query;
use WP_Site;
/**
* Class Media_Source_Taxonomy
*
* @phpstan-import-type TaxonomyArgs from \Google\Web_Stories\Taxonomy\Taxonomy_Base
*/
class Media_Source_Taxonomy extends Taxonomy_Base {
public const TERM_EDITOR = 'editor';
public const TERM_POSTER_GENERATION = 'poster-generation';
public const TERM_SOURCE_VIDEO = 'source-video';
public const TERM_SOURCE_IMAGE = 'source-image';
public const TERM_VIDEO_OPTIMIZATION = 'video-optimization';
public const TERM_PAGE_TEMPLATE = 'page-template';
public const TERM_GIF_CONVERSION = 'gif-conversion';
public const TERM_RECORDING = 'recording';
/**
* Media Source key.
*/
public const MEDIA_SOURCE_KEY = 'web_stories_media_source';
/**
* Context instance.
*/
private Context $context;
/**
* Single constructor.
*
* @param Context $context Context instance.
*/
public function __construct( Context $context ) {
$this->context = $context;
$this->taxonomy_slug = 'web_story_media_source';
$this->taxonomy_post_type = 'attachment';
}
/**
* Init.
*
* @since 1.10.0
*/
public function register(): void {
$this->register_taxonomy();
add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] );
// Hide video posters from Media grid view.
add_filter( 'ajax_query_attachments_args', [ $this, 'filter_ajax_query_attachments_args' ], PHP_INT_MAX );
// Hide video posters from Media list view.
add_action( 'pre_get_posts', [ $this, 'filter_generated_media_attachments' ], PHP_INT_MAX );
// Hide video posters from web-stories/v1/media REST API requests.
add_filter( 'web_stories_rest_attachment_query', [ $this, 'filter_rest_generated_media_attachments' ], PHP_INT_MAX );
}
/**
* Act on site initialization.
*
* @since 1.29.0
*
* @param WP_Site $site The site being initialized.
*/
public function on_site_initialization( WP_Site $site ): void {
parent::on_site_initialization( $site );
$this->add_missing_terms();
}
/**
* Act on plugin activation.
*
* @since 1.29.0
*
* @param bool $network_wide Whether the activation was done network-wide.
*/
public function on_plugin_activation( bool $network_wide ): void {
parent::on_plugin_activation( $network_wide );
$this->add_missing_terms();
}
/**
* Returns all defined media source term names.
*
* @since 1.29.0
*
* @return string[] Media sources
*/
public function get_all_terms(): array {
$consts = ( new ReflectionClass( $this ) )->getConstants();
/**
* List of terms.
*
* @var string[] $terms
*/
$terms = array_values(
array_filter(
$consts,
static fn( $key ) => str_starts_with( $key, 'TERM_' ),
ARRAY_FILTER_USE_KEY
)
);
return $terms;
}
/**
* Registers additional REST API fields upon API initialization.
*
* @since 1.10.0
*/
public function rest_api_init(): void {
// Custom field, as built in term update require term id and not slug.
register_rest_field(
$this->taxonomy_post_type,
self::MEDIA_SOURCE_KEY,
[
'get_callback' => [ $this, 'get_callback_media_source' ],
'schema' => [
'description' => __( 'Media source.', 'web-stories' ),
'type' => 'string',
'enum' => $this->get_all_terms(),
'context' => [ 'view', 'edit', 'embed' ],
],
'update_callback' => [ $this, 'update_callback_media_source' ],
]
);
}
/**
* Filters the attachment data prepared for JavaScript.
*
* @since 1.0.0
*
* @param array|mixed $response Array of prepared attachment data.
* @return array|mixed $response Filtered attachment data.
*
* @template T
*
* @phpstan-return ($response is array<T> ? array<T> : mixed)
*/
public function wp_prepare_attachment_for_js( $response ) {
if ( ! \is_array( $response ) ) {
return $response;
}
$response[ self::MEDIA_SOURCE_KEY ] = $this->get_callback_media_source( $response );
return $response;
}
/**
* Force media attachment as string instead of the default array.
*
* @since 1.0.0
*
* @param array<string, mixed> $prepared Prepared data before response.
*/
public function get_callback_media_source( array $prepared ): string {
/**
* Taxonomy ID.
*
* @var int $id
*/
$id = $prepared['id'];
$terms = get_the_terms( $id, $this->taxonomy_slug );
if ( \is_array( $terms ) && ! empty( $terms ) ) {
return array_shift( $terms )->slug;
}
return '';
}
/**
* Update rest field callback.
*
* @since 1.0.0
*
* @param string $value Value to update.
* @param WP_Post $post Object to update on.
* @return true|\WP_Error
*/
public function update_callback_media_source( string $value, WP_Post $post ) {
$check = wp_set_object_terms( $post->ID, $value, $this->taxonomy_slug );
if ( is_wp_error( $check ) ) {
return $check;
}
return true;
}
/**
* Filters the attachment query args to hide generated video poster images.
*
* Reduces unnecessary noise in the Media grid view.
*
* @since 1.10.0
*
* @param array<string, mixed>|mixed $args Query args.
* @return array<string, mixed>|mixed Filtered query args.
*
* @template T
*
* @phpstan-return ($args is array<T> ? array<T> : mixed)
*/
public function filter_ajax_query_attachments_args( $args ) {
if ( ! \is_array( $args ) ) {
return $args;
}
$args['tax_query'] = $this->get_exclude_tax_query( $args ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
return $args;
}
/**
* Filters the current query to hide generated video poster images and source video.
*
* Reduces unnecessary noise in the Media list view.
*
* @since 1.10.0
*
* @param WP_Query $query WP_Query instance, passed by reference.
*/
public function filter_generated_media_attachments( WP_Query $query ): void {
if ( is_admin() && $query->is_main_query() && $this->context->is_upload_screen() ) {
$tax_query = $query->get( 'tax_query' );
$query->set( 'tax_query', $this->get_exclude_tax_query( [ 'tax_query' => $tax_query ] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
}
}
/**
* Filters the current query to hide generated video poster images.
*
* Reduces unnecessary noise in media REST API requests.
*
* @since 1.10.0
*
* @param array<string, mixed>|mixed $args Query args.
* @return array<string, mixed>|mixed Filtered query args.
*
* @template T
*
* @phpstan-return ($args is array<T> ? array<T> : mixed)
*/
public function filter_rest_generated_media_attachments( $args ) {
if ( ! \is_array( $args ) ) {
return $args;
}
$args['tax_query'] = $this->get_exclude_tax_query( $args ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
return $args;
}
/**
* Adds missing terms to the taxonomy.
*
* @since 1.29.0
*/
private function add_missing_terms(): void {
$existing_terms = get_terms(
[
'taxonomy' => $this->get_taxonomy_slug(),
'hide_empty' => false,
'fields' => 'slugs',
]
);
if ( is_wp_error( $existing_terms ) ) {
return;
}
$missing_terms = array_diff( $this->get_all_terms(), $existing_terms );
foreach ( $missing_terms as $term ) {
wp_insert_term( $term, $this->get_taxonomy_slug() );
}
}
/**
* Taxonomy args.
*
* @since 1.12.0
*
* @return array<string,mixed> Taxonomy args.
*
* @phpstan-return TaxonomyArgs
*/
protected function taxonomy_args(): array {
return [
'label' => __( 'Source', 'web-stories' ),
'public' => false,
'rewrite' => false,
'hierarchical' => false,
'show_in_rest' => true,
'rest_namespace' => self::REST_NAMESPACE,
'rest_controller_class' => Stories_Terms_Controller::class,
];
}
/**
* Returns the tax query needed to exclude generated video poster images and source videos.
*
* @param array<string, mixed> $args Existing WP_Query args.
* @return array<int|string, mixed> Tax query arg.
*/
private function get_exclude_tax_query( array $args ): array {
/**
* Tax query.
*
* @var array<int|string, mixed> $tax_query
*/
$tax_query = ! empty( $args['tax_query'] ) ? $args['tax_query'] : [];
/**
* Filter whether generated attachments should be hidden in the media library.
*
* @since 1.16.0
*
* @param bool $enabled Whether the taxonomy check should be applied.
* @param array $args Existing WP_Query args.
*/
$enabled = apply_filters( 'web_stories_hide_auto_generated_attachments', true, $args );
if ( true !== $enabled ) {
return $tax_query;
}
/**
* Merge with existing tax query if needed,
* in a nested way so WordPress will run them
* with an 'AND' relation. Example:
*
* [
* 'relation' => 'AND', // implicit.
* [ this query ],
* [ [ any ], [ existing ], [ tax queries] ]
* ]
*/
$new_tax_query = [
'relation' => 'AND',
[
'taxonomy' => $this->taxonomy_slug,
'field' => 'slug',
'terms' => [
self::TERM_POSTER_GENERATION,
self::TERM_SOURCE_VIDEO,
self::TERM_SOURCE_IMAGE,
self::TERM_PAGE_TEMPLATE,
],
'operator' => 'NOT IN',
],
];
if ( ! empty( $tax_query ) ) {
$new_tax_query[] = [ $tax_query ];
}
return $new_tax_query;
}
}

View File

@@ -0,0 +1,420 @@
<?php
/**
* Class SVG.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media;
use DOMDocument;
use DOMElement;
use Google\Web_Stories\Experiments;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories_Dependencies\enshrined\svgSanitize\Sanitizer;
use WP_Error;
/**
* Class SVG
*
* @since 1.3.0
*/
class SVG extends Service_Base {
/**
* File extension.
*
* @since 1.3.0
*/
public const EXT = 'svg';
/**
* Mime type.
*
* @since 1.3.0
*/
public const MIME_TYPE = 'image/svg+xml';
/**
* Cached list of SVG files and their contents.
* Speeds up access during the same request.
*
* @since 1.3.0
*
* @var string[]
*/
protected array $svgs = [];
/**
* Experiments instance.
*
* @since 1.3.0
*
* @var Experiments Experiments instance.
*/
private Experiments $experiments;
/**
* SVG constructor.
*
* @since 1.3.0
*
* @param Experiments $experiments Experiments instance.
* @return void
*/
public function __construct( Experiments $experiments ) {
$this->experiments = $experiments;
}
/**
* Register filters and actions.
*
* @since 1.3.0
*/
public function register(): void {
if ( ! $this->experiments->is_experiment_enabled( 'enableSVG' ) ) {
return;
}
add_filter( 'web_stories_allowed_mime_types', [ $this, 'web_stories_allowed_mime_types' ] );
// Check if svg uploads, already enabled.
if ( $this->svg_already_enabled() ) {
add_filter( 'mime_types', [ $this, 'mime_types_add_svg' ] );
return;
}
add_filter( 'upload_mimes', [ $this, 'upload_mimes_add_svg' ] ); // phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.upload_mimes
add_filter( 'mime_types', [ $this, 'mime_types_add_svg' ] );
add_filter( 'wp_handle_upload_prefilter', [ $this, 'wp_handle_upload' ] );
add_filter( 'wp_generate_attachment_metadata', [ $this, 'wp_generate_attachment_metadata' ], 10, 3 );
add_filter( 'wp_check_filetype_and_ext', [ $this, 'wp_check_filetype_and_ext' ], 10, 5 );
add_filter( 'site_option_upload_filetypes', [ $this, 'filter_list_of_allowed_filetypes' ] );
}
/**
* Enable SVG upload.
*
* @since 1.3.0
*
* @param array<string, string> $mime_types Mime types keyed by the file extension regex corresponding to those types.
* @return array<string, string>
*/
public function upload_mimes_add_svg( array $mime_types ): array {
// allow SVG file upload.
$mime_types['svg'] = self::MIME_TYPE;
$mime_types['svgz'] = self::MIME_TYPE;
return $mime_types;
}
/**
* Adds SVG to list of mime types and file extensions
*
* @since 1.3.0
*
* @param string[] $mime_types Mime types keyed by the file extension regex
* corresponding to those types.
* @return array<string, string>
*/
public function mime_types_add_svg( array $mime_types ): array {
// allow SVG files.
$mime_types['svg'] = self::MIME_TYPE;
return array_unique( $mime_types );
}
/**
* Add SVG to allowed mime types.
*
* @since 1.3.0
*
* @param array<string, string[]> $mime_types Associative array of allowed mime types per media type (image, audio, video).
* @return array<string, string[]>
*/
public function web_stories_allowed_mime_types( array $mime_types ): array {
$mime_types['vector'][] = self::MIME_TYPE;
return $mime_types;
}
/**
* Add svg file type to allow file in multisite.
*
* @since 1.3.0
*
* @param string $value List of allowed file types.
* @return string List of allowed file types.
*/
public function filter_list_of_allowed_filetypes( string $value ): string {
$filetypes = explode( ' ', $value );
if ( ! \in_array( self::EXT, $filetypes, true ) ) {
$filetypes[] = self::EXT;
$value = implode( ' ', $filetypes );
}
return $value;
}
/**
* Hook into metadata generation and get height and width for SVG file.
*
* @since 1.3.0
*
* @param array<string,mixed> $metadata An array of attachment meta data.
* @param int $attachment_id Current attachment ID.
* @param string $context Additional context. Can be 'create' when metadata
* was initially created for new attachment.
* @return array<string,mixed> Filtered metadata.
*/
public function wp_generate_attachment_metadata( array $metadata, int $attachment_id, string $context ): array {
if ( 'create' !== $context ) {
return $metadata;
}
$attachment = get_post( $attachment_id );
$mime_type = get_post_mime_type( $attachment );
if ( self::MIME_TYPE !== $mime_type ) {
return $metadata;
}
$file = get_attached_file( $attachment_id );
if ( false === $file ) {
return $metadata;
}
$size = $this->get_svg_size( $file );
// Check if image size failed to generate and return if so.
if ( is_wp_error( $size ) ) {
return $metadata;
}
return [
'width' => (int) $size['width'],
'height' => (int) $size['height'],
'file' => _wp_relative_upload_path( $file ),
'filesize' => (int) filesize( $file ),
'sizes' => [],
];
}
/**
* Hook into upload and error if size could not be generated.
*
* @since 1.3.0
*
* @param array $upload {
* Array of upload data.
*
* @type string $file Filename of the newly-uploaded file.
* @type string $url URL of the newly-uploaded file.
* @type string $type Mime type of the newly-uploaded file.
* @type string $tmp_name Temporary file name.
* }
* @return string[]
*
* @phpstan-param array{file: string, url: string, type: string, tmp_name: string} $upload
*/
public function wp_handle_upload( array $upload ): array {
if ( self::MIME_TYPE !== $upload['type'] ) {
return $upload;
}
$sanitized = $this->sanitize( $upload['tmp_name'] );
if ( is_wp_error( $sanitized ) ) {
return [ 'error' => $sanitized->get_error_message() ];
}
$size = $this->get_svg_size( $upload['tmp_name'] );
if ( is_wp_error( $size ) ) {
return [ 'error' => $size->get_error_message() ];
}
return $upload;
}
/**
* Work around for incorrect mime type.
*
* @since 1.3.0
*
* @param array $wp_check_filetype_and_ext {
* Values for the extension, mime type, and corrected filename.
*
* @type string|false $ext File extension, or false if the file doesn't match a mime type.
* @type string|false $type File mime type, or false if the file doesn't match a mime type.
* @type string|false $proper_filename File name with its correct extension, or false if it cannot be
* determined.
* }
* @param string $file Full path to the file.
* @param string $filename The name of the file (may differ from $file due to
* $file being in a tmp directory).
* @param string[]|null|false $mimes Array of mime types keyed by their file extension regex.
* @param string|bool $real_mime The actual mime type or false if the type cannot be determined.
* @return array{ext?: string, type?: string, proper_filename?: bool}
*
* @phpstan-param array{ext?: string, type?: string, proper_filename?: bool} $wp_check_filetype_and_ext
*/
public function wp_check_filetype_and_ext( array $wp_check_filetype_and_ext, string $file, string $filename, $mimes, $real_mime ): array {
if ( 'image/svg' === $real_mime ) {
$wp_check_filetype_and_ext = [
'ext' => self::EXT,
'type' => self::MIME_TYPE,
'proper_filename' => false,
];
}
return $wp_check_filetype_and_ext;
}
/**
* Helper function to check if svg uploads are already enabled.
*
* @since 1.3.0
*/
private function svg_already_enabled(): bool {
$allowed_mime_types = get_allowed_mime_types();
$mime_types = array_values( $allowed_mime_types );
return \in_array( self::MIME_TYPE, $mime_types, true );
}
/**
* Get SVG image size.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @since 1.3.0
*
* @param string $file Path to SVG file.
* @return array|WP_Error
*
* @phpstan-return array{width: int, height: int}|WP_Error
*/
protected function get_svg_size( string $file ) {
$svg = $this->get_svg_data( $file );
$xml = $this->get_xml( $svg );
if ( false === $xml ) {
return new \WP_Error( 'invalid_xml_svg', __( 'Invalid XML in SVG.', 'web-stories' ) );
}
$width = (int) $xml->getAttribute( 'width' );
$height = (int) $xml->getAttribute( 'height' );
// If height and width are not set, try the viewport attribute.
if ( ! $width || ! $height ) {
$view_box = $xml->getAttribute( 'viewBox' );
if ( empty( $view_box ) ) {
$view_box = $xml->getAttribute( 'viewbox' );
}
$pieces = explode( ' ', $view_box );
if ( 4 === \count( $pieces ) ) {
[, , $width, $height] = $pieces;
}
}
if ( ! $width || ! $height ) {
return new \WP_Error( 'invalid_svg_size', __( 'Unable to generate SVG image size.', 'web-stories' ) );
}
return array_map( 'absint', compact( 'width', 'height' ) );
}
/**
* Sanitize the SVG
*
* @since 1.3.0
*
* @param string $file File path.
* @return true|WP_Error
*/
protected function sanitize( string $file ) {
$dirty = $this->get_svg_data( $file );
$sanitizer = new Sanitizer();
$clean = $sanitizer->sanitize( $dirty );
if ( empty( $clean ) ) {
return new \WP_Error( 'invalid_xml_svg', __( 'Invalid XML in SVG.', 'web-stories' ) );
}
$errors = $sanitizer->getXmlIssues();
if ( \count( $errors ) > 1 ) {
return new \WP_Error( 'insecure_svg_file', __( "Sorry, this file couldn't be sanitized so for security reasons wasn't uploaded.", 'web-stories' ) );
}
return true;
}
/**
* Get xml document.
*
* @since 1.3.0
*
* @param string $svg String of xml.
* @return DOMElement|false
*/
protected function get_xml( string $svg ) {
$dom = new DOMDocument();
$dom->preserveWhiteSpace = false;
$dom->strictErrorChecking = false;
$errors = libxml_use_internal_errors( true );
$loaded = $dom->loadXML( $svg );
if ( ! $loaded ) {
return false;
}
$node = $dom->getElementsByTagName( 'svg' )->item( 0 );
libxml_clear_errors();
libxml_use_internal_errors( $errors );
if ( ! $node ) {
return false;
}
return $node;
}
/**
* Get SVG data.
*
* @since 1.3.0
*
* @param string $file File path.
* @return string File contents.
*/
protected function get_svg_data( string $file ): string {
$key = md5( $file );
if ( ! isset( $this->svgs[ $key ] ) ) {
if ( is_readable( $file ) ) {
$this->svgs[ $key ] = (string) file_get_contents( $file ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown
} else {
$this->svgs[ $key ] = '';
}
}
return $this->svgs[ $key ];
}
}

View File

@@ -0,0 +1,115 @@
<?php
/**
* Class Types
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media;
/**
* Class Types
*/
class Types {
/**
* Returns a list of allowed file types.
*
* @since 1.5.0
*
* @param string[] $mime_types Array of mime types.
* @return string[]
*/
public function get_file_type_exts( array $mime_types = [] ): array {
$allowed_file_types = [];
$all_mime_types = get_allowed_mime_types();
foreach ( $all_mime_types as $ext => $mime ) {
if ( \in_array( $mime, $mime_types, true ) ) {
array_push( $allowed_file_types, ...explode( '|', $ext ) );
}
}
sort( $allowed_file_types );
return $allowed_file_types;
}
/**
* Returns a list of allowed mime types per media type (image, audio, video).
*
* @since 1.0.0
*
* @return array<string, string[]> List of allowed mime types.
*/
public function get_allowed_mime_types(): array {
$default_allowed_mime_types = [
'image' => [
'image/webp',
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
],
'audio' => [
'audio/mpeg',
'audio/aac',
'audio/wav',
'audio/ogg',
],
'caption' => [ 'text/vtt' ],
'vector' => [],
'video' => [
'video/mp4',
'video/webm',
],
];
/**
* Filter list of allowed mime types.
*
* This can be used to add additionally supported formats, for example by plugins
* that do video transcoding.
*
* @since 1.0.0
*
* @param array<string, string[]> $default_allowed_mime_types Associative array of allowed mime types per media type (image, audio, video).
*/
$allowed_mime_types = apply_filters( 'web_stories_allowed_mime_types', $default_allowed_mime_types );
/**
* Media type.
*
* @var string $media_type
*/
foreach ( array_keys( $default_allowed_mime_types ) as $media_type ) {
if ( ! \is_array( $allowed_mime_types[ $media_type ] ) || empty( $allowed_mime_types[ $media_type ] ) ) {
$allowed_mime_types[ $media_type ] = $default_allowed_mime_types[ $media_type ];
}
// Only add currently supported mime types.
$allowed_mime_types[ $media_type ] = array_values( array_intersect( $allowed_mime_types[ $media_type ], get_allowed_mime_types() ) );
}
return $allowed_mime_types;
}
}

View File

@@ -0,0 +1,65 @@
<?php
/**
* Class Captions
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media\Video;
use Google\Web_Stories\Service_Base;
/**
* Class Captions
*/
class Captions extends Service_Base {
/**
* Initializes the File_Type logic.
*
* @since 1.7.0
*/
public function register(): void {
add_filter( 'site_option_upload_filetypes', [ $this, 'filter_list_of_allowed_filetypes' ] );
}
/**
* Add VTT file type to allow file in multisite.
*
* @param string|mixed $value List of allowed file types.
* @return string|mixed List of allowed file types.
*/
public function filter_list_of_allowed_filetypes( $value ) {
if ( ! \is_string( $value ) ) {
return $value;
}
$filetypes = explode( ' ', $value );
if ( ! \in_array( 'vtt', $filetypes, true ) ) {
$filetypes[] = 'vtt';
$value = implode( ' ', $filetypes );
}
return $value;
}
}

View File

@@ -0,0 +1,111 @@
<?php
/**
* Class Is_Gif
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media\Video;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Service_Base;
/**
* Class Is_Gif
*/
class Is_Gif extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* The post meta key.
*/
public const IS_GIF_POST_META_KEY = 'web_stories_is_gif';
/**
* Init.
*
* @since 1.23.0
*/
public function register(): void {
$this->register_meta();
}
/**
* Register post meta
*
* @since 1.23.0
*/
public function register_meta(): void {
register_post_meta(
'attachment',
self::IS_GIF_POST_META_KEY,
[
'sanitize_callback' => 'rest_sanitize_boolean',
'type' => 'boolean',
'description' => __( 'Whether the video is to be considered a GIF', 'web-stories' ),
'show_in_rest' => true,
'default' => false,
'single' => true,
'object_subtype' => 'attachment',
]
);
}
/**
* Filters the attachment data prepared for JavaScript.
*
* @since 1.23.0
*
* @param array|mixed $response Array of prepared attachment data.
* @return array|mixed Response data.
*
* @template T
*
* @phpstan-return ($response is array<T> ? array<T> : mixed)
*/
public function wp_prepare_attachment_for_js( $response ) {
if ( ! \is_array( $response ) ) {
return $response;
}
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $response['id'];
$response[ self::IS_GIF_POST_META_KEY ] = get_post_meta( $post_id, self::IS_GIF_POST_META_KEY, true );
return $response;
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::IS_GIF_POST_META_KEY );
}
}

View File

@@ -0,0 +1,231 @@
<?php
/**
* Class Muting
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media\Video;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Service_Base;
use WP_Error;
use WP_Post;
/**
* Class Muting
*/
class Muting extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* Is muted.
*/
public const IS_MUTED_POST_META_KEY = 'web_stories_is_muted';
/**
* The muted video id post meta key.
*/
public const MUTED_ID_POST_META_KEY = 'web_stories_muted_id';
/**
* Is muted.
*/
public const IS_MUTED_REST_API_KEY = 'web_stories_is_muted';
/**
* Register.
*
* @since 1.10.0
*/
public function register(): void {
$this->register_meta();
add_action( 'delete_attachment', [ $this, 'delete_video' ] );
add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] );
}
/**
* Register meta for attachment post type.
*
* @since 1.10.0
*/
public function register_meta(): void {
register_meta(
'post',
self::IS_MUTED_POST_META_KEY,
[
'type' => 'boolean',
'description' => __( 'Whether the video is muted', 'web-stories' ),
'default' => false,
'single' => true,
'object_subtype' => 'attachment',
]
);
register_meta(
'post',
self::MUTED_ID_POST_META_KEY,
[
'sanitize_callback' => 'absint',
'type' => 'integer',
'description' => __( 'ID of muted video.', 'web-stories' ),
'show_in_rest' => true,
'default' => 0,
'single' => true,
'object_subtype' => 'attachment',
]
);
}
/**
* Registers additional REST API fields upon API initialization.
*
* @since 1.10.0
*/
public function rest_api_init(): void {
register_rest_field(
'attachment',
self::IS_MUTED_REST_API_KEY,
[
'get_callback' => [ $this, 'get_callback_is_muted' ],
'schema' => [
'type' => [ 'boolean', 'null' ],
'description' => __( 'Whether the video is muted', 'web-stories' ),
'default' => null,
'context' => [ 'view', 'edit', 'embed' ],
'arg_options' => [
'sanitize_callback' => 'rest_sanitize_boolean',
],
],
'update_callback' => [ $this, 'update_callback_is_muted' ],
]
);
}
/**
* Filters the attachment data prepared for JavaScript.
*
* @since 1.10.0
*
* @param array|mixed $response Array of prepared attachment data.
* @return array|mixed Response data.
*
* @template T
*
* @phpstan-return ($response is array<T> ? array<T> : mixed)
*/
public function wp_prepare_attachment_for_js( $response ) {
if ( ! \is_array( $response ) ) {
return $response;
}
if ( 'video' === $response['type'] ) {
$response[ self::IS_MUTED_REST_API_KEY ] = $this->get_callback_is_muted( $response );
}
return $response;
}
/**
* Get the attachment's post meta.
*
* @since 1.10.0
*
* @param array<string, mixed> $prepared Array of data to add to.
*/
public function get_callback_is_muted( array $prepared ): ?bool {
/**
* Attachment ID.
*
* @var int $id
*/
$id = $prepared['id'];
/**
* Muted value.
*
* @var bool|null $value
*/
$value = get_metadata_raw( 'post', $id, self::IS_MUTED_POST_META_KEY, true );
if ( null === $value ) {
return $value;
}
return rest_sanitize_boolean( $value );
}
/**
* Update the attachment's post meta.
*
* @since 1.10.0
*
* @param mixed $value Value to updated.
* @param WP_Post $post Post object to be updated.
* @return true|WP_Error
*/
public function update_callback_is_muted( $value, WP_Post $post ) {
$object_id = $post->ID;
$name = self::IS_MUTED_REST_API_KEY;
$meta_key = self::IS_MUTED_POST_META_KEY;
if ( ! current_user_can( 'edit_post_meta', $object_id, $meta_key ) ) {
return new \WP_Error(
'rest_cannot_update',
/* translators: %s: Custom field key.**/
sprintf( __( 'Sorry, you are not allowed to edit the %s custom field.', 'web-stories' ), $name ),
[
'key' => $name,
'status' => rest_authorization_required_code(),
]
);
}
update_post_meta( $object_id, $meta_key, $value );
return true;
}
/**
* Deletes associated meta data when a video is deleted.
*
* @since 1.26.0
*
* @param int $attachment_id ID of the attachment to be deleted.
*/
public function delete_video( int $attachment_id ): void {
delete_metadata( 'post', 0, self::MUTED_ID_POST_META_KEY, $attachment_id, true );
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::MUTED_ID_POST_META_KEY );
delete_post_meta_by_key( self::IS_MUTED_POST_META_KEY );
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* Class Optimization
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media\Video;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Service_Base;
/**
* Class Optimization
*/
class Optimization extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* The optimized video id post meta key.
*/
public const OPTIMIZED_ID_POST_META_KEY = 'web_stories_optimized_id';
/**
* Init.
*
* @since 1.10.0
*/
public function register(): void {
$this->register_meta();
add_action( 'delete_attachment', [ $this, 'delete_video' ] );
}
/**
* Register meta
*
* @since 1.15.0
*/
public function register_meta(): void {
register_meta(
'post',
self::OPTIMIZED_ID_POST_META_KEY,
[
'sanitize_callback' => 'absint',
'type' => 'integer',
'description' => __( 'ID of optimized video.', 'web-stories' ),
'show_in_rest' => true,
'default' => 0,
'single' => true,
'object_subtype' => 'attachment',
]
);
}
/**
* Deletes associated meta data when a video is deleted.
*
* @since 1.26.0
*
* @param int $attachment_id ID of the attachment to be deleted.
*/
public function delete_video( int $attachment_id ): void {
delete_metadata( 'post', 0, self::OPTIMIZED_ID_POST_META_KEY, $attachment_id, true );
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::OPTIMIZED_ID_POST_META_KEY );
}
}

View File

@@ -0,0 +1,279 @@
<?php
/**
* Class Poster
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media\Video;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Media\Media_Source_Taxonomy;
use Google\Web_Stories\Service_Base;
use WP_Post;
/**
* Class Poster
*/
class Poster extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* The poster post meta key.
*/
public const POSTER_POST_META_KEY = 'web_stories_is_poster';
/**
* The poster id post meta key.
*/
public const POSTER_ID_POST_META_KEY = 'web_stories_poster_id';
/**
* Media_Source_Taxonomy instance.
*
* @var Media_Source_Taxonomy Experiments instance.
*/
protected Media_Source_Taxonomy $media_source_taxonomy;
/**
* Poster constructor.
*
* @since 1.12.0
*
* @param Media_Source_Taxonomy $media_source_taxonomy Media_Source_Taxonomy instance.
*/
public function __construct( Media_Source_Taxonomy $media_source_taxonomy ) {
$this->media_source_taxonomy = $media_source_taxonomy;
}
/**
* Init.
*
* @since 1.10.0
*/
public function register(): void {
$this->register_meta();
add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
add_action( 'delete_attachment', [ $this, 'delete_video_poster' ] );
add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ], 10, 2 );
}
/**
* Register meta for attachment post type.
*
* @since 1.10.0
*/
public function register_meta(): void {
register_meta(
'post',
self::POSTER_ID_POST_META_KEY,
[
'sanitize_callback' => 'absint',
'type' => 'integer',
'description' => __( 'Attachment id of generated poster image.', 'web-stories' ),
'show_in_rest' => true,
'default' => 0,
'single' => true,
'object_subtype' => 'attachment',
]
);
}
/**
* Registers additional REST API fields upon API initialization.
*
* @since 1.0.0
*/
public function rest_api_init(): void {
register_rest_field(
'attachment',
'featured_media',
[
'schema' => [
'description' => __( 'The ID of the featured media for the object.', 'web-stories' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
],
]
);
register_rest_field(
'attachment',
'featured_media_src',
[
'get_callback' => [ $this, 'get_callback_featured_media_src' ],
'schema' => [
'description' => __( 'URL, width and height.', 'web-stories' ),
'type' => 'object',
'properties' => [
'src' => [
'type' => 'string',
'format' => 'uri',
],
'width' => [
'type' => 'integer',
],
'height' => [
'type' => 'integer',
],
'generated' => [
'type' => 'boolean',
],
],
'context' => [ 'view', 'edit', 'embed' ],
],
]
);
}
/**
* Get attachment source for featured media.
*
* @since 1.0.0
*
* @param array<string, mixed> $prepared Prepared data before response.
* @return array<string, mixed>
*/
public function get_callback_featured_media_src( array $prepared ): array {
/**
* Featured media ID.
*
* @var int|null $id
*/
$id = $prepared['featured_media'] ?? null;
$image = [];
if ( $id ) {
$image = $this->get_thumbnail_data( $id );
}
return $image;
}
/**
* Filters the attachment data prepared for JavaScript.
*
* @since 1.0.0
*
* @param array<string, mixed>|mixed $response Array of prepared attachment data.
* @param WP_Post $attachment Attachment object.
* @return array<string, mixed>|mixed $response
*
* @template T
*
* @phpstan-return ($response is array<T> ? array<T> : mixed)
*/
public function wp_prepare_attachment_for_js( $response, WP_Post $attachment ) {
if ( ! \is_array( $response ) ) {
return $response;
}
if ( 'video' === $response['type'] ) {
$thumbnail_id = (int) get_post_thumbnail_id( $attachment );
$image = '';
if ( 0 !== $thumbnail_id ) {
$image = $this->get_thumbnail_data( $thumbnail_id );
}
$response['featured_media'] = $thumbnail_id;
$response['featured_media_src'] = $image;
}
return $response;
}
/**
* Get poster image data.
*
* @since 1.0.0
*
* @param int $thumbnail_id Attachment ID.
* @return array{src?: string, width?: int, height?: int, generated?: bool}
*/
public function get_thumbnail_data( int $thumbnail_id ): array {
$img_src = wp_get_attachment_image_src( $thumbnail_id, 'full' );
if ( ! $img_src ) {
return [];
}
[ $src, $width, $height ] = $img_src;
$generated = $this->is_poster( $thumbnail_id );
return compact( 'src', 'width', 'height', 'generated' );
}
/**
* Deletes associated poster image when a video is deleted.
*
* This prevents the poster image from becoming an orphan because it is not
* displayed anywhere in WordPress or the story editor.
*
* @since 1.0.0
*
* @param int $attachment_id ID of the attachment to be deleted.
*/
public function delete_video_poster( int $attachment_id ): void {
/**
* Post ID.
*
* @var int|string $post_id
*/
$post_id = get_post_meta( $attachment_id, self::POSTER_ID_POST_META_KEY, true );
if ( empty( $post_id ) ) {
return;
}
// Used in favor of slow meta queries.
$is_poster = $this->is_poster( (int) $post_id );
if ( $is_poster ) {
wp_delete_attachment( (int) $post_id, true );
}
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::POSTER_ID_POST_META_KEY );
delete_post_meta_by_key( self::POSTER_POST_META_KEY );
}
/**
* Helper util to check if attachment is a poster.
*
* @since 1.2.1
*
* @param int $post_id Attachment ID.
*/
protected function is_poster( int $post_id ): bool {
$terms = get_the_terms( $post_id, $this->media_source_taxonomy->get_taxonomy_slug() );
if ( \is_array( $terms ) && ! empty( $terms ) ) {
$slugs = wp_list_pluck( $terms, 'slug' );
return \in_array( $this->media_source_taxonomy::TERM_POSTER_GENERATION, $slugs, true );
}
return false;
}
}

View File

@@ -0,0 +1,138 @@
<?php
/**
* Class Trimming
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Media\Video;
use Google\Web_Stories\Infrastructure\HasMeta;
use Google\Web_Stories\Infrastructure\PluginUninstallAware;
use Google\Web_Stories\Service_Base;
/**
* Class Trimming
*/
class Trimming extends Service_Base implements HasMeta, PluginUninstallAware {
/**
* The trim video post meta key.
*/
public const TRIM_POST_META_KEY = 'web_stories_trim_data';
/**
* Is trim.
*/
public const TRIM_DATA_KEY = 'trim_data';
/**
* Register.
*
* @since 1.12.0
*/
public function register(): void {
$this->register_meta();
add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ] );
}
/**
* Register meta for attachment post type.
*
* @since 1.12.0
*/
public function register_meta(): void {
register_meta(
'post',
self::TRIM_POST_META_KEY,
[
'type' => 'object',
'description' => __( 'Video trim data.', 'web-stories' ),
'show_in_rest' => [
'schema' => [
'properties' => [
'original' => [
'description' => __( 'Original attachment id', 'web-stories' ),
'type' => 'integer',
],
'start' => [
'description' => __( 'Start time.', 'web-stories' ),
'type' => 'string',
],
'end' => [
'description' => __( 'End time.', 'web-stories' ),
'type' => 'string',
],
],
],
],
'default' => [
'original' => 0,
],
'single' => true,
'object_subtype' => 'attachment',
]
);
}
/**
* Filters the attachment data prepared for JavaScript.
*
* @since 1.12.0
*
* @param array|mixed $response Array of prepared attachment data.
* @return array|mixed Response data.
*
* @template T
*
* @phpstan-return ($response is array<T> ? array<T> : mixed)
*/
public function wp_prepare_attachment_for_js( $response ) {
if ( ! \is_array( $response ) ) {
return $response;
}
if ( 'video' === $response['type'] ) {
/**
* Post ID.
*
* @var int $post_id
*/
$post_id = $response['id'];
$response[ self::TRIM_DATA_KEY ] = get_post_meta( $post_id, self::TRIM_POST_META_KEY, true );
}
return $response;
}
/**
* Act on plugin uninstall.
*
* @since 1.26.0
*/
public function on_plugin_uninstall(): void {
delete_post_meta_by_key( self::TRIM_POST_META_KEY );
}
}

View File

@@ -0,0 +1,141 @@
<?php
/**
* Class Mgid
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2023 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\HasRequirements;
/**
* Class MGID
*/
class Mgid extends Service_Base implements HasRequirements {
/**
* Settings instance.
*
* @var Settings Settings instance.
*/
private Settings $settings;
/**
* Analytics constructor.
*
* @since 1.33.0
*
* @param Settings $settings Settings instance.
* @return void
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Initializes all hooks.
*
* @since 1.33.0
*/
public function register(): void {
add_action( 'web_stories_print_analytics', [ $this, 'print_mgid_tag' ] );
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because settings needs to be registered first.
*
* @since 1.33.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'settings' ];
}
/**
* Prints the <amp-story-auto-ads> tag for single stories.
*
* @since 1.33.0
*/
public function print_mgid_tag(): void {
$widget = $this->get_widget_id();
$enabled = $this->is_enabled();
if ( ! $enabled || ! $widget ) {
return;
}
$configuration = [
'ad-attributes' => [
'type' => 'mgid',
'data-widget' => $widget,
],
];
/**
* Filters MGID configuration passed to `<amp-story-auto-ads>`.
*
* @since 1.33.0
*
* @param array $settings MGID configuration.
* @param string $widget MGID Widget ID.
*/
$configuration = apply_filters( 'web_stories_mgid_configuration', $configuration, $widget );
?>
<amp-story-auto-ads>
<script type="application/json">
<?php echo wp_json_encode( $configuration, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); ?>
</script>
</amp-story-auto-ads>
<?php
}
/**
* Returns the MGID Widget ID.
*
* @since 1.33.0
*
* @return string Widget ID.
*/
private function get_widget_id(): string {
/**
* Widget ID.
*
* @var string
*/
return $this->settings->get_setting( $this->settings::SETTING_NAME_MGID_WIDGET_ID );
}
/**
* Returns if MGID is enabled.
*
* @since 1.33.0
*/
private function is_enabled(): bool {
return ( 'mgid' === $this->settings->get_setting( $this->settings::SETTING_NAME_AD_NETWORK, 'none' ) );
}
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* Class Add_Media_Source_Editor
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Migrations;
use Google\Web_Stories\Media\Media_Source_Taxonomy;
/**
* Class Add_Media_Source
*/
abstract class Add_Media_Source extends Migrate_Base {
/**
* Media_Source_Taxonomy instance.
*
* @var Media_Source_Taxonomy Experiments instance.
*/
protected Media_Source_Taxonomy $media_source_taxonomy;
/**
* Add_Media_Source constructor.
*
* @since 1.12.0
*
* @param Media_Source_Taxonomy $media_source_taxonomy Media_Source_Taxonomy instance.
*/
public function __construct( Media_Source_Taxonomy $media_source_taxonomy ) {
$this->media_source_taxonomy = $media_source_taxonomy;
}
/**
* Add the editor term, to make sure it exists.
*
* @since 1.9.0
*/
public function migrate(): void {
wp_insert_term( $this->get_term(), $this->media_source_taxonomy->get_taxonomy_slug() );
}
/**
* Override this method.
*
* @since 1.9.0
*/
abstract protected function get_term(): string;
}

View File

@@ -0,0 +1,43 @@
<?php
/**
* Class Add_Media_Source_Editor
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Migrations;
/**
* Class Add_Media_Source_Editor
*/
class Add_Media_Source_Editor extends Add_Media_Source {
/**
* Term name.
*
* @since 1.9.0
*/
protected function get_term(): string {
return $this->media_source_taxonomy::TERM_EDITOR;
}
}

View File

@@ -0,0 +1,43 @@
<?php
/**
* Class Add_Media_Source_Gif_Conversion
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Migrations;
/**
* Class Add_Media_Source_Gif_Conversion
*/
class Add_Media_Source_Gif_Conversion extends Add_Media_Source {
/**
* Term name.
*
* @since 1.9.0
*/
protected function get_term(): string {
return $this->media_source_taxonomy::TERM_GIF_CONVERSION;
}
}

View File

@@ -0,0 +1,43 @@
<?php
/**
* Class Add_Media_Source_Page_Template
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Migrations;
/**
* Class Add_Media_Source_Page_Template
*/
class Add_Media_Source_Page_Template extends Add_Media_Source {
/**
* Term name.
*
* @since 1.14.0
*/
protected function get_term(): string {
return $this->media_source_taxonomy::TERM_PAGE_TEMPLATE;
}
}

View File

@@ -0,0 +1,43 @@
<?php
/**
* Class Add_Media_Source_Recording
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2022 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Migrations;
/**
* Class Add_Media_Source_Recording
*/
class Add_Media_Source_Recording extends Add_Media_Source {
/**
* Term name.
*
* @since 1.23.0
*/
protected function get_term(): string {
return $this->media_source_taxonomy::TERM_RECORDING;
}
}

View File

@@ -0,0 +1,43 @@
<?php
/**
* Class Add_Media_Source_Source_Video
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Migrations;
/**
* Class Add_Media_Source_Source_Video
*/
class Add_Media_Source_Source_Image extends Add_Media_Source {
/**
* Term name.
*
* @since 1.9.0
*/
protected function get_term(): string {
return $this->media_source_taxonomy::TERM_SOURCE_IMAGE;
}
}

Some files were not shown because too many files have changed in this diff Show More