Your IP : 216.73.217.18


Current Path : /home/digilove/public_html/plugins/system/forseo/vendor/weeblr/forseo/controller/
Upload File :
Current File : /home/digilove/public_html/plugins/system/forseo/vendor/weeblr/forseo/controller/pageprocessor.php

<?php
/**
 * Project: 4SEO
 *
 * @package                 4SEO
 * @copyright               Copyright Weeblr llc - 2020-2024
 * @author                  Yannick Gaultier - Weeblr llc
 * @license                 GNU General Public License version 3; see LICENSE.md
 * @version                 6.2.0.2478
 *
 * 2024-10-03
 */

namespace Weeblr\Forseo\Controller;

use Weeblr\Wblib\Forseo\Wb;
use Weeblr\Wblib\Forseo\Base;
use Weeblr\Wblib\Forseo\System;
use Weeblr\Wblib\Forseo\Html;

use Weeblr\Forseo\Data;
use Weeblr\Forseo\Model;
use Weeblr\Forseo\Model\Injector;
use Weeblr\Forseo\Helper;
use Weeblr\Wblib\Forseo\Joomla\StringHelper\StringHelper;
use Weeblr\Wblib\Forseo\Joomla\Uri;

// Security check to ensure this file is being included by a parent file.
defined('_JEXEC') || defined('WBLIB_EXEC') || die;

/**
 * Render a story page
 */
class Pageprocessor extends Base\Base
{
	/**
	 * @var Injector\Variables Convenience dynamic variables model instance.
	 */
	protected $variablesModel;

	/**
	 * @var Data\Requestinfo Convenience instance of the current request computed data.
	 */
	private $requestInfo = null;

	/**
	 * @var Data\Page Convenience instance of the current request details.
	 */
	private $pageData = null;

	/**
	 * @var Data\Meta Convenience instance of the current request meta data.
	 */
	private $pageMeta = null;

	/**
	 * @var Helper\Meta Convenience instance of metadata helper.
	 */
	private $metaHelper = null;

	/**
	 * @var Model\Config Convenience instance of pages config.
	 */
	private $pagesConfig = null;

	/**
	 * @var Model\Config Convenience instance of socialNetworks config.
	 */
	private $socialNetworksConfig = null;

	/**
	 * @var Model\Config Convenience instance of structured data config.
	 */
	private $sdConfig = null;

	/**
	 * Initialize convenience instances.
	 */
	public function __construct()
	{
		parent::__construct();

		$this->variablesModel = $this->factory->getThe('forseo.variablesExpander');

		$this->requestInfo          = $this->factory->getThe('forseo.requestInfo');
		$pageDataCollector          = $this->factory->getThe('forseo.pageDataCollector');
		$this->pageData             = $pageDataCollector->get();
		$this->pageMeta             = $pageDataCollector->getMeta();
		$this->pagesConfig          = $this->factory->getThis('forseo.config', 'pages');
		$this->socialNetworksConfig = $this->factory->getThis('forseo.config', 'socialNetworks');
		$this->sdConfig             = $this->factory->getThis('forseo.config', 'sd');
		$this->metaHelper           = $this->factory->getA(Helper\Meta::class);
	}

	/**
	 * Run content replacers set for onContentPrepare events, ie Main content and modules.
	 *
	 * [
	 * 'modified' => false,
	 * 'context'  => $context,
	 * 'content'  => $row,
	 * 'params'   => $params,
	 * 'page'     => $page
	 * ]
	 *
	 * 'modified' must be set to true for any change to be taken into consideration.
	 *
	 * @param array $contentData
	 *
	 * @return array
	 */
	public function onContentPrepare($contentData)
	{
		$contentObject = Wb\arrayGet($contentData, 'content');
		if (empty($contentObject) || empty($contentObject->text))
		{
			return $contentData;
		}

		try
		{
			$originalText = $contentObject->text;

			// Variable expansion. We do not care about context, variables are always expanded if present
			$expansionCount = 0;
			$processed      = $this->expandVariables(
				$contentObject->text,
				$expansionCount
			);

			$contentData['modified'] = !empty($expansionCount);
			$contentObject->text     = $processed;

			// auto-generated description for some content types
			$context = Wb\arrayGet($contentData, 'context');

			// compute auto desc at OnContentPrepare for articles for best results.
			$this->extractMetaDescription(
				$context,
				$contentObject
			);

			// user-specified page image for some content types
			$this->extractPageImageFromArticleData(
				$contentObject->text,
				$context,
				$contentObject
			);

			if (!empty($this->platform->getConfig()->get('caching', 0)))
			{
				// if caching is enabled, we restore the original text,
				// which has the replacement tags and not the replacement
				// values.
				// The tags will be stored in the cache and so will eventually
				// be replaced at onAfterDispatch. That's required for instance for
				// time-based variables. If replacing them at onAfterContent, their
				// value will be stored into the cache and won't update at each page load.
				// Note that this won't help for full page caching (ie the cache system plugin).
				// Note also that we do perform the replacement and then restore the original text
				// so that meta description and page image methods above operate on the replaced
				// content. It won't be good in some cases, but it's a choice.
				// Maybe adding a filter would be best.
				$contentObject->text = $originalText;
			}
		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());
		}

		return $contentData;
	}

	/**
	 * Search a piece of content OR an object (for some contexts) for a representative image.
	 *
	 * @param null|string $content
	 * @param null|string $context
	 * @param null        $contentObject
	 *
	 * @return Pageprocessor
	 * @throws \Exception
	 */
	private function extractPageImageFromArticleData($content, $context = '', $contentObject = null)
	{
		// do we already have all images we need?
		$pageImage        = $this->requestInfo->get(
			'page_image'
		);
		$pageSharingImage = $this->requestInfo->get(
			'page_sharing_image'
		);
		if (!empty($pageImage) || !empty($pageSharingImage))
		{
			// already found an image, don't spend more time.
			return $this;
		}

		$appConfig    = $this->factory->getThis('forseo.config', 'app');
		$imageSpec    = $appConfig->get('imageDetectionRequireSizeSd');
		$ogpImageSpec = $appConfig->get('imageDetectionRequireSizeOgp');

		// 1 - custom meta data from DB

		// 2 - Full or Intro image
		/**
		 * Filter automatically detected images from content data object.
		 *
		 * @api     forseo
		 * @package 4SEO\filter\frontend\meta
		 * @var forseo_extract_page_images_from_content_data
		 * @since   1.3.0
		 *
		 * @param array     $extractedImages
		 * @param string    $context       An option string representing the context, the content type.
		 * @param string    $content       Rendered content.
		 * @param Object    $contentObject Data object holding the content data.
		 * @param Data\Page $pageData      Collected request information.
		 * @param Data\Meta $pageMeta      Collected meta data about the request.
		 *
		 * @return array
		 *
		 */
		$detectedImages = $this->factory
			->getThe('hook')
			->filter(
				'forseo_extract_page_images_from_content_data',
				[
					'page_image'         => $pageImage,
					'page_sharing_image' => $pageSharingImage
				],
				$context,
				$content,
				$contentObject,
				$this->pageData,
				$this->pageMeta
			);

		$pageImage        = Wb\arrayGet($detectedImages, 'page_image');
		$pageSharingImage = Wb\arrayGet($detectedImages, 'page_sharing_image');

		// still here, no luck from data object, search the actual content
		if (empty($pageImage))
		{
			$pageImage        = $this->metaHelper->searchPageImage(
				$content,
				$imageSpec,
				$this->sdConfig->get('imageDetectionMethod', Html\Image::IMAGE_SEARCH_LARGEST)
			);
			$pageSharingImage = $this->metaHelper->searchPageImage(
				$content,
				$ogpImageSpec,
				$this->socialNetworksConfig->get('imageDetectionMethod', Html\Image::IMAGE_SEARCH_LARGEST)
			);
		}

		if (!empty($pageImage))
		{
			$this->requestInfo->set(
				'page_image',
				$pageImage
			);
		}

		if (!empty($pageSharingImage))
		{
			$this->requestInfo->set(
				'page_sharing_image',
				$pageSharingImage
			);
		}

		return $this;
	}

	/**
	 * Update variables content after component has rendered and hopefully all onAfterDispatchHandlers have ran.
	 *
	 * @throws \Exception
	 */
	public function onAfterDispatchComplete()
	{
		if (!$this->canRun())
		{
			return;
		}

		$this->catchUnauthorizedAccess();

		// get current output
		$document       = $this->platform->getDocument();
		$currentContent = $document->getBuffer('component');

		if (empty($currentContent))
		{
			return;
		}

		// re-run replacement in case some content was created after our onContentPrepare handle ran.
		try
		{
			// extract page information now available, after main content has been rendered.
			if ($this->platform->isHtmlPage())
			{
				$this->extractContentAfterDispatch(
					$currentContent
				);
			}

			$expansionCount = 0;
			$processed      = $this->expandVariables(
				$currentContent,
				$expansionCount
			);

			if (!empty($expansionCount))
			{
				$document->setBuffer(
					$processed,
					'component'
				);
			}
		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());
		}
	}

	/**
	 * Joomla does not throw exceptions when a user is not authorized to access a page.
	 * Instead it only sets a 403 header and:
	 * - either redirects to the login page
	 * - or just render the page normally, with only a message enqueued.
	 *
	 * This means we won't catch that error and error pages rules are not triggered.
	 * We try here to detect that condition and throw an exception.
	 *
	 * @return void
	 * @throws \Exception
	 */
	private function catchUnauthorizedAccess()
	{
		if (System\Http::RETURN_FORBIDDEN !== $this->platform->getHttpStatus())
		{
			return;
		}

		$rules = $this->factory
			->getA(Rules::class)
			->getRulesPerType(
				Data\Rule::TYPE_ERROR_PAGE
			);

		foreach ($rules as $rule)
		{
			if ($this->factory
				->getA(Helper\Errorpage::class)
				->ruleAppliesToError(
					$rule,
					new \Exception('Forbidden', System\Http::RETURN_FORBIDDEN)
				)
			)
			{
				// clear queued message
				$this->platform->clearAppMessageQueue();

				// if still there, throw a 403 exception
				throw new \Exception($this->platform->t('JERROR_ALERTNOAUTHOR'), 403);
			}
		}
	}

	/**
	 * @param null|string $buffer
	 *
	 * @throws \Exception
	 */
	private function extractContentAfterDispatch($buffer)
	{
		// get title and description from regular rendering process
		$this->extractMetadata();

		// If platform caching is enabled, we must rely on platform information we collected
		// during analysis, if we have any
		// Note: if Joomla has already cached the page, then we won't have the proper image until
		// analysis have actually taken place, which may be much later.
		$cachingEnabled = !empty($this->platform->getConfig()->get('caching', 0));
		if ($cachingEnabled)
		{
			// caching is enabled (progressive or conservative)
			// use stored values if available
			$storedMeta = $this->factory->getThe('forseo.pageDataCollector')->getMeta()->getMeta();

			$description = Wb\arrayGet($storedMeta, ['platform', 'description']);
			if (empty($description))
			{
				$description = Wb\arrayGet($storedMeta, ['auto', 'description']);
			}
			if (!empty($description))
			{
				$this->requestInfo->set(
					'page_description',
					$description
				);
			}

			$image = Wb\arrayGet($storedMeta, ['platform', 'image']);
			if (empty($image))
			{
				$image = Wb\arrayGet($storedMeta, ['auto', 'image']);
			}
			if (!empty($image))
			{
				$this->requestInfo->set(
					'page_image',
					$image
				);
			}
			$sharingImage = Wb\arrayGet($storedMeta, ['platform', 'sharing_image']);
			if (empty($sharingImage))
			{
				$sharingImage = Wb\arrayGet($storedMeta, ['auto', 'sharing_image']);
			}
			if (!empty($sharingImage))
			{
				$this->requestInfo->set(
					'page_sharing_image',
					$sharingImage
				);
			}
		}

		if (!$cachingEnabled)
		{
			$appConfig    = $this->factory->getThis('forseo.config', 'app');
			$imageSpec    = $appConfig->get('imageDetectionRequireSizeSd');
			$ogpImageSpec = $appConfig->get('imageDetectionRequireSizeOgp');

			// Description and representative images extraction.
			// This time we look at the entire page so we look for the largest image always.
			// Using first image would likely almost never work, we'd catch logos and accessories images.
			$this->extractMetaDescription('', $buffer)
				 ->extractPageImage($buffer, 'page_image', $imageSpec, Html\Image::IMAGE_SEARCH_LARGEST)
				// representative image for the page, to be used in SD, OGP, TCards
				 ->extractPageImage($buffer, 'page_sharing_image', $ogpImageSpec, Html\Image::IMAGE_SEARCH_LARGEST);
		}
	}

	/**
	 * Extract title,, desc, canonical and robots from HTML document,
	 * storing them into the requestInfo object.
	 *
	 * @throws \Exception
	 */
	public function extractMetadata()
	{
		// get title and description from regular rendering process
		$this->requestInfo->set(
			'page_title',
			$this->platform->getTitle()
		);

		$this->requestInfo->set(
			'page_description',
			$this->platform->getDescription()
		);

		$this->requestInfo->set(
			'page_canonical',
			$this->platform->getCanonical()
		);

		$this->requestInfo->set(
			'page_robots',
			$this->platform->getMetadata('robots')
		);
	}

	/**
	 * Compute a meta description based on a piece of content,
	 * if none already exists in RequestInfo.
	 *
	 * @param null|string         $context A context representing the content type.
	 * @param null|string| Object $content An object or a string representing the content.
	 *
	 * @return $this
	 * @throws \Exception
	 */
	private function extractMetaDescription($context, $content)
	{
		if ($this->pagesConfig->isFalsy('metaAutoDescIfMissing'))
		{
			return $this;
		}

		$currentAutoDescription = $this->requestInfo->get('page_auto_description');
		if (empty($currentAutoDescription))
		{
			/**
			 * Filter an automatically computed meta description for a piece of content.
			 *
			 * @api     forseo
			 * @package 4SEO\filter\frontend\meta
			 * @var forseo_auto_build_description
			 * @since   1.0.0
			 *
			 * @param string        $autoDescription
			 * @param string        $context  An option string representing the context, the content type.
			 * @param string|Object $content  Either a string or an object holding the content data.
			 * @param Data\Page     $pageData Collected request information.
			 * @param Data\Meta     $pageMeta Collected meta data about the request.
			 *
			 * @return bool
			 *
			 */
			$autoDescription = $this->factory
				->getThe('hook')
				->filter(
					'forseo_auto_build_description',
					$currentAutoDescription,
					$context,
					$content,
					$this->pageData,
					$this->pageMeta
				);

			// if plugins have not provided us with a description
			if (empty($autoDescription))
			{
				// try with default code
				$text = $content;
				if (is_object($text) && isset($content->text))
				{
					$text = $content->text;
				}
				else if (is_object($text))
				{
					// we don't know where the actual content is stored
					// in the object, can't use it.
					return $this;
				}

				// fallback to generic description extraction from raw HTML.
				$autoDescription = $this->metaHelper->buildDescriptionFromContent(
					$text
				);
			}

			$this->requestInfo->set(
				'page_auto_description',
				$autoDescription
			);
		}

		return $this;
	}

	/**
	 * Extract representative image from the page, based on an image size specification,
	 * if none already exists in RequestInfo.
	 *
	 * @param null|string $content
	 * @param string      $type
	 * @param array       $imageSpec
	 * @param int         $selectionMode
	 *
	 * @return $this
	 * @throws \Exception
	 */
	private function extractPageImage($content, $type, $imageSpec, $selectionMode)
	{
		// do we already have all images we need?
		$pageImage = $this->requestInfo->get(
			$type
		);

		// if not, run a new search, this time on the entire page.
		if (empty($pageImage))
		{
			$pageImage = $this->metaHelper->searchPageImage(
				$content,
				$imageSpec
			);
			if (!empty($pageImage))
			{
				$this->requestInfo->set(
					$type,
					$pageImage,
					$selectionMode
				);
			}
		}

		return $this;
	}

	/**
	 * Update document meta data based on:
	 *
	 * - user set meta per page
	 * - user set meta per rule (future)
	 * - computed meta if none set
	 *
	 * Meta are:
	 *
	 *   - meta model: page title
	 *   - meta model: page description
	 *
	 * NB: This is separate from other SEO data such as Structured data, OGP, TCards,...
	 * as title and description may be used by other extensions. As such, they need to be made
	 * available as early as possible, in our case at onAfterDispatch.
	 */
	public function injectMetaData()
	{
		if (!$this->platform->isHtmlPage())
		{
			return;
		}

		try
		{
			$this->factory->getA(Model\Injector\Meta::class)
						  ->title()
						  ->description()
						  ->generator();
		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());
		}
	}

	/**
	 * Inject robots tag in page, using default if none set otherwise.
	 * Default is set in config/app.php.
	 *
	 */
	public function injectRobotsTag($body)
	{
		if (!$this->platform->isHtmlPage())
		{
			return $body;
		}

		$originalBody = $body;

		try
		{
			return $this->factory->getA(Model\Injector\Robots::class)
								 ->robots($body);

		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());

			return $originalBody;
		}
	}

	/**
	 * Inject document Analytics snippets based on analytics rules.
	 * NB: we do NOT use onBeforeCompilehead for now as it does not allow control
	 * on where the script declaration is inserted.
	 *
	 * @param null|string $body The full body of the current response.
	 *
	 * @return string
	 */
	public function injectStructuredData($body)
	{
		/**
		 * Filter whether structured data should be injected for this request.
		 *
		 * @api     forseo
		 * @package 4SEO\filter\frontend\features
		 * @var forseo_should_inject_structured_data
		 * @since   1.0.0
		 *
		 * @param bool      $injectData
		 * @param Data\Page $pageData
		 *
		 * @return bool
		 *
		 */
		if (
			$this->platform->isHtmlPage()
			&&
			$this->canRun(
				'forseo_should_inject_structured_data',
				true
			)
		)
		{
			$renderedSd = $this->factory->getA(Model\Injector\Sd::class)
										->build()
										->render();
			if (!empty($renderedSd))
			{
				$body = System\Strings::tagInBuffer(
					$body,
					'</head>',
					"\t" . $renderedSd,
					[
						'firstOnly' => true,
						'where'     => 'before'
					]
				);
			}
		}

		return $body;
	}

	/**
	 * Update document SEO-related data, before CMS compiles the head section, based on:
	 *
	 * - user set meta per page
	 * - user set meta per rule (future)
	 * - computed meta if none set
	 *
	 * SEO data are:
	 *
	 *   - sd model: structured data
	 *   - ogp model: OGP
	 *   - tcard model: Twitter Cards
	 */
	public function injectSeoData()
	{
		if (!$this->platform->isHtmlPage())
		{
			return;
		}

		try
		{
			/**
			 * Filter whether SEO Data (Structured data, OGP tags, Twitter Cards) should be injected for this request.
			 *
			 * @api     forseo
			 * @package 4SEO\filter\frontend\features
			 * @var forseo_should_inject_seo_data
			 * @since   1.0.0
			 *
			 * @param bool      $injectData
			 * @param Data\Page $pageData
			 *
			 * @return bool
			 *
			 */
			if (!$this->canRun(
				'forseo_should_inject_seo_data',
				true
			))
			{
				return;
			}

			/**
			 * Filter whether OGP tags should be injected for this request.
			 *
			 * @api     forseo
			 * @package 4SEO\filter\frontend\features
			 * @var forseo_should_inject_ogp
			 * @since   1.0.0
			 *
			 * @param bool      $injectData
			 * @param Data\Page $pageData
			 *
			 * @return bool
			 *
			 */
			if ($this->canRun(
				'forseo_should_inject_ogp',
				true
			))
			{
				$this->factory->getA(Model\Injector\Ogp::class)
							  ->build()
							  ->inject();
			}

			/**
			 * Filter whether Twitter Cards tags should be injected for this request.
			 *
			 * @api     forseo
			 * @package 4SEO\filter\frontend\features
			 * @var forseo_should_inject_tcards
			 * @since   1.0.0
			 *
			 * @param bool      $injectData
			 * @param Data\Page $pageData
			 *
			 * @return bool
			 *
			 */
			if ($this->canRun(
				'forseo_should_inject_tcards',
				true
			))
			{
				$this->factory->getA(Model\Injector\Tcards::class)
							  ->build()
							  ->inject();
			}
		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());
		}
	}

	/**
	 * Search for and insert into page any custom canonical set for this URL.
	 *
	 * @return $this
	 * @throws \Exception
	 */
	public function customCanonical()
	{
		$customCanonical = $this->requestInfo
			->get('page_custom_canonical');

		if (!empty($customCanonical))
		{
			$this->metaHelper
				->injectCanonical(
					$customCanonical,
					'4SEO_custom_canonical'
				);

			// custom canonical has priority over rules
			$this->requestInfo
				->set(
					'page_canonical',
					$customCanonical
				);
		}

		return $this;
	}

	/**
	 * Automatically compute and insert a canonical in the current page if we detect
	 * it's a duplicate of another one.
	 *
	 * We identify a cluster by the value of "full_content_id". In a given cluster, the 1st URL in the
	 * Pages table is considered the canonical.
	 *
	 * @return $this
	 * @throws \Exception
	 */
	public function autoCanonical()
	{
		if (
			$this->pagesConfig->isFalsy('insertAutoCanonical')
			&&
			$this->pagesConfig->isFalsy('insertAutoSelfCanonical')
		)
		{
			return $this;
		}

		// If - up to now - no canonical was inserted into the page, try to generate one automatically.
		$platformCanonical   = $this->requestInfo->get('page_canonical');
		$insertAutoCanonical = empty($platformCanonical);

		/**
		 * Filter whether to insert an automatically computed canonical link in the current page.
		 *
		 * @api     forseo
		 * @package 4SEO\filter\frontend\pages
		 * @var forseo_pages_insert_auto_canonical
		 * @since   1.0.0
		 *
		 * @param bool      $insertAutoCanonical
		 * @param Data\Page $pageData Collected request information.
		 * @param Data\Meta $pageMeta Collected meta data about the request.
		 *
		 * @return bool
		 *
		 */
		$insertAutoCanonical = $this->factory
			->getThe('hook')
			->filter(
				'forseo_pages_insert_auto_canonical',
				$insertAutoCanonical,
				$this->pageData,
				$this->pageMeta
			);

		if (!$insertAutoCanonical)
		{
			return $this;
		}

		$pageHelper       = $this->factory->getThe('forseo.pageHelper');
		$dynamicCanonical = $pageHelper->getDynamicCanonical(
			$this->pageData
		);

		$defaultCanonical = $pageHelper->getDefaultCanonical(
			$this->pageData
				->get('full_content_id')
		);

		$currentRequestedUrl = System\Route::makeRootRelative(
			$this->pageData->get('full_url'),
			true // remove leading slash
		);

		if (
			!is_null($defaultCanonical)
			&&
			(
				is_null($dynamicCanonical)
				||
				$dynamicCanonical !== $defaultCanonical
			)
		)
		{
			$canonical = $defaultCanonical;
		}
		else
		{
			$canonical = $dynamicCanonical;
		}

		/**
		 * Filter automatically generated canonical link before it's inserted in the page.
		 *
		 * @api     forseo
		 * @package 4SEO\filter\pages
		 * @var forseo_pages_auto_canonical_link
		 * @since   4.8.0
		 *
		 * @param string    $link
		 * @param Data\Page $pageData
		 *
		 * @return array
		 *
		 */
		$canonical = $this->factory
			->getThe('hook')
			->filter(
				'forseo_pages_auto_canonical_link',
				$canonical,
				$this->pageData
			);

		// should insert self-referencing?
		if (
			!is_null($canonical)
			&&
			$this->pagesConfig->isFalsy('insertAutoSelfCanonical'))
		{
			$canonical =
				!empty($canonical) // allow self-referencing on home page
				&&
				$canonical === $this->pageData->get('full_url')
					? null
					: $canonical;
		}

		// actually insert canonical if we still have one at this point
		if (!is_null($canonical))
		{
			// store for any future re-use within the request
			$this->requestInfo->set(
				'page_canonical',
				$canonical
			);

			$this->requestInfo->set(
				'page_auto_canonical',
				$canonical
			);

			// insert a canonical
			$this->factory->getA(Helper\Meta::class)
						  ->injectCanonical(
							  $canonical,
							  '4SEO_auto_canonical'
						  );
		}

		return $this;
	}

	/**
	 * Redirect a request to the same path and query on the main website
	 * home address if configured to do so by user and conditions are met.
	 */
	public function enforceCanonicalRootUrl()
	{
		if (
			$this->pagesConfig->isTruthy('enforceCanonicalRootUrl')
			&&
			$this->platform->isFrontend()
			&&
			$this->platform->isGuest()
		)
		{
			$this->doEnforceCanonicalRootUrl();
		}
	}

	/**
	 * Redirect a request to the same path and query on the main website
	 * home address.
	 *
	 * Note on getting the request root: Joomla platform is not consistent, although this is also dependent
	 * on the web server configuration.
	 *
	 * Uri::getInstance()->getScheme() . '://' . Uri::getInstance()->getHost() . '/' will always return details about current request eg: https://example.com
	 * BUT
	 * Uri::root(false) on the same request will not give the same result. It will return the value of the virtual host, eg sometimes: https://www.example.com
	 *
	 * This prevents properly detecting www vs non-www and redirecting to the expected main site address.
	 *
	 * This was fixed by using $this->platform->getScheme() . '://' . $this->platform->getHost() . '/' to get the current request root, both of which in turn use
	 * Uri::getInstance() and seems to work reliably.
	 *
	 *
	 */
	public function doEnforceCanonicalRootUrl()
	{
		// - root != canonical root
		$requestRoot   = $this->platform->getScheme() . '://' . $this->platform->getHost();
		$requestRoot   = Wb\slashTrimJoin(
			$requestRoot,
			$this->platform->getBaseUrl(true),
			'/'
		);
		$canonicalRoot = $this->pagesConfig->get('canonicalRootUrl');

		if (empty($requestRoot) || empty($canonicalRoot))
		{
			return;
		}

		if ($requestRoot === $canonicalRoot)
		{
			return;
		}

		// now we redirect
		$currentUrl = $this->requestInfo->get('page_url');
		$targetUrl  = Wb\lTrim($currentUrl, $requestRoot);
		$targetUrl  = Wb\slashTrimJoin(
			$canonicalRoot,
			$targetUrl
		);

		if ($this->platform->canRedirect($currentUrl, $targetUrl))
		{
			// do redirect
			$this->platform->redirectTo(
				$targetUrl,
				System\Http::RETURN_MOVED
			);
		}
	}

	/**
	 * Last chance to do full page variable replacement in non-content areas of the page.
	 * Typically structured data placeholders.
	 *
	 * @param string $body
	 */
	public function expandVariablesOnPage($body)
	{
		$expansionCount = 0;
		$processed      = $this->expandVariables(
			$body,
			$expansionCount
		);

		return !empty($expansionCount)
			? $processed
			: $body;
	}

	/**
	 * Inject document Analytics snippets based on analytics rules.
	 * NB: we do NOT use onBeforeCompilehead for now as it does not allow control
	 * on where the script declaration is inserted.
	 *
	 * @param null|string $body The full body of the current response.
	 *
	 * @return string
	 */
	public function injectAnalytics($body)
	{
		/**
		 * Filter whether using cookies from analytics providers is allowed for this request.
		 *
		 * @api     forseo
		 * @package 4SEO\filter\frontend\privacy
		 * @var forseo_analytics_cookies_allowed
		 * @since   1.0.0
		 *
		 * @param bool             $cookiesAllowed
		 * @param Data\Requestinfo $requestInfo
		 *
		 * @return bool
		 *
		 */
		$cookiesAllowed = $this->canRun(
			'forseo_analytics_cookies_allowed',
			true
		);

		if (
			empty($body)
			||
			!$cookiesAllowed
			||
			!$this->platform->isHtmlPage()
		)
		{
			// do not use $this->canRun(), analytics only injected on HTML documents.
			return $body;
		}

		$originalBody = $body;

		try
		{

			$analyticsRules = $this->factory
				->getA(Model\Rules::class)
				->getRulesSpecs(
					Data\Rule::TYPE_ANALYTICS
				);

			if (empty($analyticsRules))
			{
				return $body;
			}

			return $this->factory
				->getA(Model\Injector\Analytics::class)
				->build($analyticsRules)
				->inject(
					$body
				);
		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());

			return $originalBody;
		}
	}


	/**
	 * Clean up all remaining variable tags that we may not have been able
	 * to expand prior.
	 *
	 * @param null|string $body
	 *
	 * @return string
	 */
	public function cleanVariablesTags($body)
	{
		if (!$this->canRun())
		{
			return $body;
		}

		$originalBody = $body;

		try
		{
			return $this->variablesModel->cleanVariablesTags($body);

		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());

			return $originalBody;
		}
	}

	/**
	 * Possibly inject some raw content inside HTML response.
	 *
	 * @param null|string $body
	 *
	 * @param null|int    $expansionCount
	 *
	 * @return string
	 */
	private function expandVariables($body, &$expansionCount = null)
	{
		if (
			empty($body)
			||
			false === stripos($body, '{4seo_')
			||
			!$this->canRun())
		{
			return $body;
		}

		$originalBody = $body;

		try
		{
			list($body, $expansionCount) = $this->variablesModel->expand($body);

			return $body;
		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());

			$expansionCount = null;

			return $originalBody;
		}
	}

	/**
	 * Try to cleanup microdata found in page. Done by removing itemtype attributes on:
	 *
	 * - article
	 * - aside
	 * - section
	 *
	 * @param null|string $body
	 *
	 * @return string
	 */
	public function cleanStructuredData($body)
	{
		if (
			!$this->platform->isHtmlPage()
			||
			$this->sdConfig->isFalsy('enabledCleanup')
		)
		{
			return $body;
		}

		$originalBody = $body;

		try
		{
			return $this->factory
				->getA(Model\Injector\Sd::class)
				->cleanup($body);

		}
		catch (\Throwable $e)
		{
			$this->factory->getThe('forseo.logger')->error('%s::%d %s - %s', $e->getFile(), $e->getLine(), $e->getMessage(), $e->getTraceAsString());

			return $originalBody;
		}
	}

	/**
	 * Loads and assign to requestInfo object any custom data
	 * set by user through the UI: meta title, meta description,
	 * page image, custom OGP, custom structured data, etc
	 */
	private function getUserCustomData()
	{
		static $userData = null;

		if (is_null($userData))
		{
			if (empty($this->pageData))
			{
				return $userData;
			}
			$contentId = $this->pageData->get('content_id');
			if (empty($contentId))
			{
				return $userData;
			}

			$userData = $this->factory
				->getA(Data\Meta::class)
				->loadPerColumn(
					'content_id',
					$contentId
				);
		}

		return $userData;
	}

	/**
	 * Test document type to only run on html doc or feeds.
	 * Optionally, run a hook to let plugins enable/disable
	 * a specific feature.
	 *
	 * @param string $what
	 * @param bool   $defaultCanRun
	 *
	 * @return bool
	 */
	private function canRun($what = '', $defaultCanRun = true)
	{
		if (empty($what))
		{
			return
				$this->platform->isHtmlPage()
				||
				$this->platform->isFeedPage();
		}

		/**
		 * Filter whether a specific feature, identified by a filter name, is allowed for this request.
		 *
		 * @api     forseo
		 * @package 4SEO\filter\frontend\features
		 * @var forseo_*
		 * @since   1.0.0
		 *
		 * @param bool      $shouldRunFeature
		 * @param Data\Page $pageData
		 *
		 * @return bool
		 *
		 */
		return $this->factory
			->getThe('hook')
			->filter(
				$what,
				$defaultCanRun,
				$this->pageData
			);

	}
}