\x20\40\x20\40
<?php
namespace Imagely\NGG\Display;
use Imagely\NGG\DisplayedGallery\Renderer;
/**
* NGG registers its shortcodes using Shortcodes::add($tag, $callback)
*
* We do this for the following reason:
*
* NGG is used with a wide variety of third-party themes and plugins. Some of which, do things
* a little unorthodox, and end up modifying the markup generated by the shortcode, which breaks
* the display for our users.
*
* Rather than having to explain to our userbase, "you shouldn't use NGG with [plugin x]", we
* decided to find a mechanism that would make our plugin immune from such manipulation from other
* plugins, or at least reduce likelihood of such a thing happening.
*
* This is how the mechanism works:
*
* When you register a shortcode using Shortcodes::add(), we store a reference to
* the callback internally, but add the shortcode will actually call one of the internal methods of
* this class which outputs a placeholder (@see $_privateholder_text) instead of the markup generated
* by the callback. It's just a simple string and something that a third-party plugin is unlikely to
* manipulate in any way.
*
* We then register a filter for the_content, at high a priority of PHP_INT_MAX as an attempt to make
* our hook be the last hook to be executed. This hook then substitutes the placeholders with the markup
* generated by the shortcode's callback that was provided in \Imagely\NGG\Display\Shortcodes::add()
*/
class Shortcodes {
/**
* Singleton instance of the Shortcodes class.
*
* @var Shortcodes|null
*/
private static $_instance = null;
/**
* Array of registered shortcodes.
*
* @var array
*/
private $_shortcodes = [];
/**
* Array of found shortcodes in content.
*
* @var array
*/
private $_found = [];
/**
* Placeholder text template for shortcode replacement.
*
* @var string
*/
private $_placeholder_text = 'ngg_shortcode_%d_placeholder';
/**
* Gets the singleton instance of the Shortcodes class.
*
* @return Shortcodes
*/
public static function get_instance() {
if ( \is_null( self::$_instance ) ) {
self::$_instance = new Shortcodes();
}
return self::$_instance;
}
/**
* Adds a shortcode
*
* @param string $name The shortcode name.
* @param callable $callback The callback function.
* @param callable|null $transformer Parameters transformer.
*/
public static function add( $name, $callback, $transformer = null ) {
$manager = self::get_instance();
$manager->add_shortcode( $name, $callback, $transformer );
}
/**
* Gets the list of registered shortcodes.
*
* @return string[]
*/
public function get_shortcodes() {
return $this->_shortcodes;
}
/**
* Checks if shortcode manager is disabled
*
* @return bool
*/
public function is_disabled(): bool {
return defined( 'NGG_DISABLE_SHORTCODE_MANAGER' ) && NGG_DISABLE_SHORTCODE_MANAGER;
}
/**
* Removes a previously added shortcode
*
* @param string $name The shortcode name.
*/
public static function remove( $name ) {
$manager = self::get_instance();
$manager->remove_shortcode( $name );
}
/**
* Registers hooks for shortcode management
*
* @return void
*/
public function register_hooks() {
if ( $this->is_disabled() ) {
return;
}
// For theme & plugin compatibility and to prevent the output of our shortcodes from being
// altered we substitute our shortcodes with placeholders at the start of the the_content() filter
// queue and then at the end of the the_content() queue, we substitute the placeholders with our
// actual markup.
\add_filter( 'the_content', [ $this, 'fix_nested_shortcodes' ], -1 );
\add_filter( 'the_content', [ $this, 'parse_content' ], PHP_INT_MAX );
\add_filter( 'widget_text', [ $this, 'fix_nested_shortcodes' ], -1 );
}
/**
* We're parsing our own shortcodes because WP can't yet handle nested shortcodes [ngg param="[slideshow]"]
*
* @param string $content The content to parse for nested shortcodes.
* @return string
*/
public function fix_nested_shortcodes( $content ) {
// Try to find each registered shortcode in the content.
foreach ( $this->_shortcodes as $tag => $tag_details ) {
$shortcode_start_tag = "[{$tag}";
$offset = 0;
// Find each occurrence of the shortcode.
$start_of_shortcode = \strpos( $content, $shortcode_start_tag, $offset );
// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
while ( $start_of_shortcode !== false ) {
$index = $start_of_shortcode + \strlen( $shortcode_start_tag );
$found_attribute_assignment = false;
$current_attribute_enclosure = null;
$last_found_char = '';
$content_length = \strlen( $content ) - 1;
while ( true ) {
// Parse out the shortcode, character-by-character.
$char = $content[ $index ];
if ( '"' === $char || "'" === $char && '=' === $last_found_char ) {
// Is this the closing quote for an already found attribute assignment?
if ( $found_attribute_assignment && $char === $current_attribute_enclosure ) {
$found_attribute_assignment = false;
$current_attribute_enclosure = null;
} else {
$found_attribute_assignment = true;
$current_attribute_enclosure = $char;
}
} elseif ( ']' === $char ) {
// we've found a shortcode closing tag. But, we need to ensure
// that this ] isn't within the value of a shortcode attribute.
if ( ! $found_attribute_assignment ) {
break; // exit loop - we've found the shortcode.
}
}
$last_found_char = $char;
// Prevent infinite loops in our while(TRUE).
if ( $content_length === $index ) {
break;
}
++$index;
}
// Get the shortcode.
--$index;
$shortcode = \substr( $content, $start_of_shortcode + 1, $index - $start_of_shortcode );
$shortcode_replacement = \str_replace(
[ '[', ']' ],
[ '[', ']' ],
$shortcode
); // Replace the shortcode with one that doesn't have nested shortcodes.
$content = \str_replace(
$shortcode,
$shortcode_replacement,
$content
); // Calculate the offset for the next loop iteration.
$offset = $index + 1 + \strlen( $shortcode_replacement ) - \strlen( $shortcode );
$start_of_shortcode = \strpos( $content, $shortcode_start_tag, $offset );
}
}
\reset( $this->_shortcodes );
return $content;
}
/**
* Deactivates all shortcodes
*
* @return void
*/
public function deactivate_all() {
foreach ( \array_keys( $this->_shortcodes ) as $shortcode ) {
$this->deactivate( $shortcode );
}
}
/**
* Parses the content for shortcodes and returns the substituted content
*
* @param string $content The content to parse.
* @return string
*/
public function parse_content( $content ) {
$regex = \str_replace( '%d', '(\d+)', $this->_placeholder_text );
if ( $this->is_rest_request() ) {
// Return early if we're in the REST API and shortcodes are disabled.
// Allows other plugins to disable shortcodes in the REST API.
if ( \apply_filters( 'ngg_disable_shortcodes_in_request_api', true ) ) {
return $content;
}
\ob_start();
}
if ( \preg_match_all( "/{$regex}/m", $content, $matches, PREG_SET_ORDER ) ) {
foreach ( $matches as $match ) {
$placeholder = \array_shift( $match );
$id = \array_shift( $match );
$content = \str_replace( $placeholder, $this->execute_found_shortcode( $id ), $content );
}
}
if ( $this->is_rest_request() ) {
// Pre-generating displayed gallery cache by executing shortcodes in the REST API can prevent users
// from being able to add and save blocks with lots of images and no pagination (for example a very large
// basic slideshow or pro masonry / mosaic / tile display).
if ( \apply_filters( 'ngg_disable_shortcodes_in_request_api', false ) ) {
return $content;
}
\ob_start();
}
return $content;
}
/**
* Renders a legacy shortcode
*
* @param array $params The shortcode parameters.
* @param string $inner_content The inner content.
* @return string
*/
public function render_legacy_shortcode( $params, $inner_content ) {
return Renderer::get_instance()->display_images( $params, $inner_content );
}
/**
* Executes a found shortcode by ID
*
* @param int $found_id The ID of the found shortcode.
* @return string
*/
public function execute_found_shortcode( $found_id ) {
return isset( $this->_found[ $found_id ] )
? $this->render_shortcode(
$this->_found[ $found_id ]['shortcode'],
$this->_found[ $found_id ]['params'],
$this->_found[ $found_id ]['inner_content']
)
: 'Invalid shortcode';
}
/**
* Adds a shortcode
*
* @param string $name The shortcode name.
* @param callable $callback The callback function.
* @param callable|null $transformer Parameters transformer.
* @return void
*/
public function add_shortcode( $name, $callback, $transformer = null ) {
$this->_shortcodes[ $name ] = [
'callback' => $callback,
'transformer' => $transformer,
];
if ( $this->is_disabled() ) {
// Because render_shortcode() is a wrapper to several shortcodes it requires the $name parameter and can't be passed directly to add_shortcode()
add_shortcode(
$name,
function ( $params, $inner_content ) use ( $name ) {
return $this->render_shortcode( $name, $params, $inner_content );
}
);
} else {
\add_shortcode( $name, [ $this, $name . '____wrapper' ] );
}
}
/**
* Removes a shortcode
*
* @param string $name The shortcode name.
* @return void
*/
public function remove_shortcode( $name ) {
unset( $this->_shortcodes[ $name ] );
$this->deactivate( $name );
}
/**
* De-activates a shortcode
*
* @param string $shortcode The shortcode name.
* @return void
*/
public function deactivate( $shortcode ) {
if ( isset( $this->_shortcodes[ $shortcode ] ) ) {
\remove_shortcode( $shortcode );
}
}
/**
* Checks if this is a REST request
*
* @return bool
*/
public function is_rest_request() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only checking for string presence
return \defined( 'REST_REQUEST' ) || ( isset( $_SERVER['REQUEST_URI'] ) && \strpos( $_SERVER['REQUEST_URI'], 'wp-json' ) !== false );
}
/**
* Magic method for handling shortcode wrapper calls
*
* @param string $method The method name.
* @param array $args The method arguments.
* @return string
*/
public function __call( $method, $args ) {
$params = \array_shift( $args );
$inner_content = \array_shift( $args );
$parts = \explode( '____', $method );
$shortcode = \array_shift( $parts );
if ( \doing_filter( 'the_content' ) && ! \doing_filter( 'widget_text' ) ) {
$retval = $this->replace_with_placeholder( $shortcode, $params, $inner_content );
} else { // Widgets don't use placeholders.
return $this->render_shortcode( $shortcode, $params, $inner_content );
}
return $retval;
}
/**
* Renders a shortcode
*
* @param string $shortcode The shortcode name.
* @param array $params The shortcode parameters.
* @param string $inner_content The inner content.
* @return string
*/
public function render_shortcode( $shortcode, $params = [], $inner_content = '' ) {
if ( isset( $this->_shortcodes[ $shortcode ] ) ) {
$shortcode = $this->_shortcodes[ $shortcode ];
if ( \is_callable( $shortcode['transformer'] ) ) {
$params = \call_user_func( $shortcode['transformer'], $params );
}
$method = ( \is_null( $shortcode['callback'] ) && \is_callable( $shortcode['transformer'] ) ) ? [ $this, 'render_legacy_shortcode' ] : $shortcode['callback'];
$retval = \call_user_func( $method, $params, $inner_content );
} else {
$retval = 'Invalid shortcode';
}
return $retval;
}
/**
* Prevents wptexturize
*
* @param string $shortcode The shortcode name.
* @param array $params The shortcode parameters.
* @param string $inner_content The inner content.
* @return mixed|void
*/
public function replace_with_placeholder( $shortcode, $params = [], $inner_content = '' ) {
$id = \count( $this->_found );
$this->_found[ $id ] = [
'shortcode' => $shortcode,
'params' => $params,
'inner_content' => $inner_content,
];
$placeholder = \sprintf( $this->_placeholder_text, $id );
return \apply_filters( 'ngg_shortcode_placeholder', $placeholder, $shortcode, $params, $inner_content );
}
/**
* Gets the shortcode regex pattern
*
* @return string
*/
public function get_shortcode_regex() {
$keys = \array_keys( $this->_shortcodes );
return '/' . \get_shortcode_regex( $keys ) . '/';
}
}