Source: php/class-string-replace.php

<?php
/**
 * Cloudinary string replace class, to replace URLS and other strings on shutdown.
 *
 * @package Cloudinary
 */

namespace Cloudinary;

use Cloudinary\Component\Setup;

/**
 * String replace class.
 */
class String_Replace implements Setup {

	/**
	 * Holds the plugin instance.
	 *
	 * @var Plugin Instance of the global plugin.
	 */
	public $plugin;

	/**
	 * Holds the context.
	 *
	 * @var string
	 */
	protected $context;

	/**
	 * Holds the list of strings and replacements.
	 *
	 * @var array
	 */
	protected static $replacements = array();

	/**
	 * Holds the list of strings and replacements.
	 *
	 * @var bool
	 */
	protected static $doing_save = false;

	/**
	 * Site Cache constructor.
	 *
	 * @param Plugin $plugin Global instance of the main plugin.
	 */
	public function __construct( Plugin $plugin ) {
		$this->plugin = $plugin;
	}

	/**
	 * Setup the object.
	 */
	public function setup() {
		if ( Utils::is_admin() ) {
			$this->admin_filters();
		} elseif ( Utils::is_frontend_ajax() || Utils::is_rest_api() ) {
			$this->context = 'view';
			$this->start_capture();
		} else {
			$this->public_filters();
		}
		$this->add_rest_filters();
	}

	/**
	 * Add admin filters.
	 */
	protected function admin_filters() {
		// Admin filters can call String_Replace frequently, which is fine, as performance is not an issue.
		add_filter( 'media_send_to_editor', array( $this, 'replace_strings' ), 10, 2 );
		add_filter( 'the_editor_content', array( $this, 'replace_strings' ), 10, 2 );
		add_filter( 'wp_prepare_attachment_for_js', array( $this, 'replace_strings' ), 11 );
		add_action( 'admin_init', array( $this, 'start_capture' ) );
	}

	/**
	 * Add Public Filters.
	 */
	protected function public_filters() {
		add_action( 'template_include', array( $this, 'init_debug' ), PHP_INT_MAX );
		if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) { // Not needed on REST API.
			add_action( 'parse_request', array( $this, 'init' ), - 1000 ); // Not crazy low, but low enough to catch most cases, but not too low that it may break AMP.
		}
	}

	/**
	 * Add filters for REST API.
	 */
	protected function add_rest_filters() {
		$types = get_post_types();
		foreach ( $types as $type ) {
			$post_type = get_post_type_object( $type );
			// Check if this is a rest supported type.
			if ( property_exists( $post_type, 'show_in_rest' ) && true === $post_type->show_in_rest ) {
				// Add filter only to rest supported types.
				add_filter( 'rest_prepare_' . $type, array( $this, 'pre_filter_rest_content' ), 10, 3 );
			}
		}
		add_filter( 'rest_pre_echo_response', array( $this, 'pre_filter_rest_echo' ), 900, 3 );
	}

	/**
	 * Filter the result of a rest Request before it's echoed.
	 *
	 * @param array            $result  The result of the REST API.
	 * @param \WP_REST_Server  $server  The REST server instance.
	 * @param \WP_REST_Request $request The original request.
	 *
	 * @return array
	 */
	public function pre_filter_rest_echo( $result, $server, $request ) {
		$route = trim( $request->get_route(), '/' );
		if ( 0 !== strpos( $route, REST_API::BASE ) ) {
			// Only for non-Cloudinary requests.
			$context = $request->get_param( 'context' );
			if ( ! $context || 'view' === $context ) {
				$result = $this->replace_strings( $result, $context );
			}
		}

		return $result;
	}

	/**
	 * Filter out local urls in an 'edit' context rest request ( i.e for Gutenberg ).
	 *
	 * @param \WP_REST_Response $response The post data array to save.
	 * @param \WP_Post          $post     The current post.
	 * @param \WP_REST_Request  $request  The request object.
	 *
	 * @return \WP_REST_Response
	 */
	public function pre_filter_rest_content( $response, $post, $request ) {
		$context = $request->get_param( 'context' );

		if ( 'edit' === $context ) {
			// Updating or creating a post.
			if ( in_array( $request->get_method(), array( 'PUT', 'POST' ), true ) ) {
				static::$doing_save = true;
			}
			$data = $response->get_data();
			$data = $this->replace_strings( $data, $context );
			$response->set_data( $data );
		}

		return $response;
	}

	/**
	 * Init the buffer capture and set the output callback.
	 */
	public function init() {
		if ( ! defined( 'CLD_DEBUG' ) || false === CLD_DEBUG ) {
			$this->context = 'view';
			$this->start_capture();
		}
	}

	/**
	 * Stop the buffer capture and set the output callback.
	 */
	public function start_capture() {
		ob_start( array( $this, 'replace_strings' ) );
		ob_start( array( $this, 'replace_strings' ) ); // Second call to catch early buffer flushing.
	}

	/**
	 * Init the buffer capture in debug mode.
	 *
	 * @param string $template The template being loaded.
	 *
	 * @return null|string
	 */
	public function init_debug( $template ) {
		if ( defined( 'CLD_DEBUG' ) && true === CLD_DEBUG && ! Utils::get_sanitized_text( '_bypass' ) ) {
			$this->context = 'view';
			ob_start();
			include $template; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable
			$html = ob_get_clean();
			echo $this->replace_strings( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			$template = $this->plugin->template_path . 'blank-template.php';
		}

		return $template;
	}

	/**
	 * Check if a string is set for replacement.
	 *
	 * @param string $string String to check.
	 *
	 * @return bool
	 */
	public static function string_set( $string ) {
		return isset( self::$replacements[ $string ] );
	}

	/**
	 * Check if a string is not set for replacement.
	 *
	 * @param string $string String to check.
	 *
	 * @return bool
	 */
	public static function string_not_set( $string ) {
		return ! self::string_set( $string );
	}

	/**
	 * Replace a string.
	 *
	 * @param string $search  The string to be replaced.
	 * @param string $replace The string replacement.
	 */
	public static function replace( $search, $replace ) {
		self::$replacements[ $search ] = $replace;
	}

	/**
	 * Flatten an array into content.
	 *
	 * @param array|string $content The array to flatten.
	 *
	 * @return string
	 */
	public function flatten( $content ) {
		$flat = '';
		if ( Utils::looks_like_json( $content ) ) {
			$maybe_content = json_decode( $content, true );
			if ( ! empty( $maybe_content ) ) {
				$content = $maybe_content;
			}
		}
		if ( self::is_iterable( $content ) ) {
			foreach ( $content as $item ) {
				$flat .= "\r\n" . $this->flatten( $item );
			}
		} else {
			$flat = $content;
		}

		return $flat;
	}

	/**
	 * Check if the item is iterable.
	 *
	 * @param mixed $thing Thing to check.
	 *
	 * @return bool
	 */
	public static function is_iterable( $thing ) {

		return is_array( $thing ) || is_object( $thing );
	}

	/**
	 * Prime replacement strings.
	 *
	 * @param mixed  $content The content to prime replacements for.
	 * @param string $context The context to use.
	 */
	protected function prime_replacements( $content, $context = 'view' ) {

		if ( self::is_iterable( $content ) ) {
			$content = $this->flatten( $content );
		}
		/**
		 * Do replacement action.
		 *
		 * @hook  cloudinary_string_replace
		 * @since 3.0.3 Added the `$context` argument.
		 *
		 * @param $content {string} The html of the page.
		 * @param $context {string} The render context.
		 */
		do_action( 'cloudinary_string_replace', $content, $context );
	}

	/**
	 * Replace string in HTML.
	 *
	 * @param string|array $content The HTML.
	 * @param string       $context The context to use.
	 *
	 * @return string
	 */
	public function replace_strings( $content, $context = 'view' ) {
		static $last_content;
		if ( empty( $content ) || $last_content === $content ) {
			return $content; // Bail if nothing to replace.
		}
		// Captured a front end request, since the $context will be an int.
		if ( ! empty( $this->context ) ) {
			$context = $this->context;
		}
		if ( Utils::looks_like_json( $content ) ) {
			$json_maybe = json_decode( $content, true );
			if ( ! empty( $json_maybe ) ) {
				$content = $json_maybe;
			}
		}
		$this->prime_replacements( $content, $context );
		if ( ! empty( self::$replacements ) ) {
			$content = self::do_replace( $content );
		}
		self::reset();
		$last_content = ! empty( $json_maybe ) ? wp_json_encode( $content ) : $content;

		return $last_content;
	}

	/**
	 * Do string replacements.
	 *
	 * @param array|string $content The content to do replacements on.
	 *
	 * @return array|string
	 */
	public static function do_replace( $content ) {

		if ( self::is_iterable( $content ) ) {
			foreach ( $content as &$item ) {
				$item = self::do_replace( $item );
			}
		} elseif ( is_string( $content ) ) {
			$content = str_replace( array_keys( self::$replacements ), array_values( self::$replacements ), $content );
		}

		return $content;
	}

	/**
	 * Check if we are currently saving a REST request (i.e. Gutenberg).
	 * This is used to prevent double replacements.
	 *
	 * @return bool
	 */
	public function doing_save() {
		return static::$doing_save;
	}

	/**
	 * Reset internal replacements.
	 */
	public static function reset() {
		self::$replacements = array();
	}
}