<?php
namespace Cms\ContentBundle\Service;
use Cms\AssetsBundle\Model\StructureBag\ScriptStructureBag;
use Cms\AssetsBundle\Service\AssetCatalog;
use Cms\ContainerBundle\Entity\Container;
use Cms\ContentBundle\Model\Inliner;
use Cms\ContentBundle\Model\Inliners\ContentInliner;
use Cms\ContentBundle\Model\Inliners\RegionInliner;
use Cms\CoreBundle\Model\HtmlOverridableInterface;
use Cms\CoreBundle\Service\Transcoding\Transcoder;
use Cms\FrontendBundle\Model\FrontendGlobals;
use Cms\ModuleBundle\Entity\ModuleEntity;
use Cms\ThemeBundle\Entity\InnerLayout;
use Cms\ThemeBundle\Entity\OuterLayout;
use Cms\ThemeBundle\Entity\Template;
use Cms\ThemeBundle\Model\Layout;
use Cms\ThemeBundle\Service\PackageManager;
use Cms\ThemeBundle\Service\ThemeManager;
use Cms\ThemeBundle\Service\Twig\Loader\DatabaseLoader;
use Cms\WidgetBundle\Model\AbstractWidgetProcessor;
use Cms\WidgetBundle\Model\WidgetObject;
use Cms\WidgetBundle\Service\WidgetDependencyInjector;
use Cms\WidgetBundle\Service\WidgetManager;
use Doctrine\Common\Util\ClassUtils;
use Twig\Environment;
/**
* Class FrontendRenderer
* @package Cms\ContentBundle\Service
*/
final class FrontendRenderer
{
const MODES = array(
self::MODES__LIVE,
self::MODES__EDITOR,
);
const MODES__LIVE = 'live';
const MODES__EDITOR = 'editor';
/**
* @var AssetCatalog
*/
private AssetCatalog $assetCatalog;
/**
* @var Environment
*/
private Environment $twig;
/**
* @var DatabaseLoader
*/
private DatabaseLoader $databaseLoader;
/**
* @var PackageManager
*/
private PackageManager $packageManager;
/**
* @var ThemeManager
*/
private ThemeManager $themeManager;
/**
* @var WidgetManager
*/
private WidgetManager $widgetManager;
/**
* @var WidgetDependencyInjector
*/
private WidgetDependencyInjector $widgetDependencyInjector;
/**
* @var array
*/
private $activeContent;
/**
* @var FrontendGlobals
*/
private $globals;
/**
* @var string
*/
private $mode;
/**
* @var string
*/
private $realMode;
/**
* @param AssetCatalog $assetCatalog
* @param Environment $twig
* @param DatabaseLoader $databaseLoader
* @param PackageManager $packageManager
* @param ThemeManager $themeManager
* @param WidgetManager $widgetManager
* @param WidgetDependencyInjector $widgetDependencyInjector
*/
public function __construct(
AssetCatalog $assetCatalog,
Environment $twig,
DatabaseLoader $databaseLoader,
PackageManager $packageManager,
ThemeManager $themeManager,
WidgetManager $widgetManager,
WidgetDependencyInjector $widgetDependencyInjector
)
{
$this->assetCatalog = $assetCatalog;
$this->twig = $twig;
$this->databaseLoader = $databaseLoader;
$this->packageManager = $packageManager;
$this->themeManager = $themeManager;
$this->widgetManager = $widgetManager;
$this->widgetDependencyInjector = $widgetDependencyInjector;
}
/**
* @return FrontendGlobals
*/
public function peekGlobals()
{
return $this->globals;
}
/**
* @return Inliner
*/
public function peekActiveContent()
{
return $this->activeContent;
}
/**
* @return string
*/
public function peekMode()
{
return $this->mode;
}
/**
* @return string
*/
public function peekRealMode()
{
return $this->realMode;
}
/**
* @param FrontendGlobals $globals
* @param string $mode
* @return $this
*/
public function prepare(FrontendGlobals $globals, $mode = null)
{
$this->globals = $globals;
if ( ! array_search($mode, self::MODES)) {
$mode = null;
}
if ($mode === null) {
$mode = self::MODES__LIVE;
}
$this->mode = $mode;
$this->realMode = $mode;
return $this;
}
/**
* @param mixed $content
* @param mixed $default
*/
private function generateActiveContent($content, $default = null)
{
// set the active content
$this->activeContent = $this->flattenContent(
$this->normalizeContent($content, $default)
);
// if we are editing, we now want to treat as live; only topmost content is editable
if ($this->mode === self::MODES__EDITOR) {
$this->mode = self::MODES__LIVE;
}
}
/**
* @param string $template
* @param array $parameters
* @return string
* @throws \Exception
*/
public function renderAjax(string $template, array $parameters = []): string
{
return $this->renderTheme(array(
'content' => $this->renderRaw(
$template,
$parameters
),
));
}
/**
* @param mixed $content
* @return string
* @throws \Exception
*/
public function renderPage($content)
{
// set editing class
if ($this->mode === self::MODES__EDITOR) {
$this->peekGlobals()->getAssetsOrganizer()->getHtmlTag()->append(
'class',
'edit--content'
);
}
// set the active content
$this->generateActiveContent($content);
// with this done, we then need to render out our inner layout, using its own content
return $this->renderInnerLayout(true);
}
/**
* @param WidgetObject $widget
* @return string
* @throws \Exception
*/
public function renderWidget(WidgetObject $widget)
{
static $dbgStack = [];
array_push($dbgStack, sprintf(
'%s::%s',
ClassUtils::getClass($widget),
$widget->__uid__
));
$dbgevt = trim(sprintf(
'WIDGET PROCESSING %s',
implode(' ', $dbgStack)
));
$this->globals->debugStart($dbgevt);
// obtain the widget handler
$handler = $this->widgetManager->loadFor($widget);
// get the processor if it has one
$processor = null;
if ($handler->hasProcessor()) {
$processor = $handler->getProcessor(
$this->widgetDependencyInjector->getContainer($this),
);
}
// run the processor to generate additional parameters for the widget
$parameters = [];
$plugin = [];
$styles = [];
$scripts = [];
$error = null;
if ($processor !== null) {
try {
$parameters = $processor->process($widget, $this);
$plugin = $processor->plugin($widget, $this);
$styles = $processor->styles($widget, $this);
$scripts = $processor->scripts($widget, $this);
} catch (\Exception $e) {
$error = $e;
}
}
// generate the final parameters array
$parameters = array_merge(
$parameters,
array(
'_plugin' => $plugin,
'_styles' => $styles,
'_scripts' => $scripts,
'_htmlId' => ($widget->getContainer() !== null && ! empty($widget->getContainer()->getHtmlId()))
? $widget->getContainer()->getHtmlId()
: sprintf(
'cms-widget-%s',
$widget->__uid__
),
'_widget' => $widget,
'_handler' => $handler,
'_error' => $error,
'_twig' => $this->databaseLoader->determine(
$this->globals->getTheme(),
sprintf(
'/widgets/%s/build/%s.html.twig',
$handler->getKey(),
(isset($parameters[AbstractWidgetProcessor::SCHOOLNOW_OVERRIDE]) && $parameters[AbstractWidgetProcessor::SCHOOLNOW_OVERRIDE] === true) ? 'sn' : 'tpl'
)
),
'_loader' => $this->databaseLoader,
)
);
$this->globals->debugStop($dbgevt);
array_pop($dbgStack);
// render based on the template
$rendered = $this->render('Widget', $parameters);
// done
return $rendered;
}
/**
* @param string $template
* @param array $parameters
* @return string
*/
public function renderRaw($template, array $parameters = [])
{
// generate the final parameters array
$parameters = array_merge(
$parameters,
array(
'_twig' => $this->databaseLoader->determine(
$this->globals->getTheme(),
$template
),
'_loader' => $this->databaseLoader,
)
);
// render based on the template
$rendered = $this->render($template, $parameters);
// done
return $rendered;
}
/**
* Takes a set of widgets and generates on string of rendered content from them.
*
* @param array $widgets
* @return string
*/
public function renderWidgets(array &$widgets)
{
// holder
$rendered = [];
// loop over all widgets and append their result
foreach ($widgets as $widget) {
$rendered[] = $this->renderWidget($widget);
}
// done
return implode("\n\n", $rendered);
}
/**
* Takes a content array, and flattens the widgets contained within it.
*
* @param Inliner $content
* @return Inliner
*/
private function flattenContent(Inliner $content)
{
// get keys
$keys = $content->keys();
// loop over all the keys
foreach ($keys as $key) {
// get the content item
$stuff = $content->get($key);
// if we have a string, assume we are already rendered
if ( ! is_string($stuff)) {
// render this one, expect an array of widget objects
$content->set($key, $this->renderWidgets($stuff));
}
}
// done
return $content;
}
/**
* @param mixed $content
* @return string
*/
public function renderInnerLayout($content = null)
{
// set editing class
$taken = $this->peekGlobals()->getAssetsOrganizer()->getHtmlTag()->contains(
'class',
array(
'edit--content',
)
);
if ($this->mode === self::MODES__EDITOR && ! $taken) {
$this->peekGlobals()->getAssetsOrganizer()->getHtmlTag()->append(
'class',
'edit--inner-tpl'
);
}
// set the active
$this->generateActiveContent($content, $this->globals->getInnerLayout());
// render the outer layout using its content
return $this->renderOuterLayout(true);
}
/**
* @param mixed $content
* @return string
*/
public function renderOuterLayout($content = null)
{
// normalize the content, default to our inner layout content
$content = $this->normalizeContent(
$content,
$this->globals->getOuterLayout()
);
// set editing class
$taken = $this->peekGlobals()->getAssetsOrganizer()->getHtmlTag()->contains(
'class',
array(
'edit--inner-tpl',
'edit--content',
)
);
if ($this->mode === self::MODES__EDITOR && ! $taken) {
$this->peekGlobals()->getAssetsOrganizer()->getHtmlTag()->append(
'class',
'edit--outer-tpl'
);
}
// pass our content to the base
return $this->renderOuterLayoutBase(
($content instanceof ContentInliner)
? $content->content()
: false
);
}
/**
* @param mixed $content
* @return string
*/
public function renderOuterLayoutBase($content = null)
{
// set the active
$this->generateActiveContent($content, $this->globals->getPackage()->getLayout(
$this->globals->getOuterLayout()->getBase()
));
// this needs to actually render out a file as we need to merge inline content regions
$rendered = $this->render(
'OuterLayoutBase',
array(
'_twig' => $this->databaseLoader->determine(
$this->globals->getTheme(),
sprintf(
'/layouts/%s/build/tpl.html.twig',
$this->globals->getOuterLayout()->getBase()
)
)
)
);
// now, we render the theme
return $this->renderTheme(array(
'content' => $rendered,
));
}
/**
*
*/
private function mergeHtmlOverrides()
{
// starting overrides, the inner and outer layouts
$overrides = array(
$this->globals->getOuterLayout(),
$this->globals->getInnerLayout(),
);
// add in the containers
$overrides = array_merge($overrides, array_reverse(
$this->globals->getContainers()
));
// if there is a thing, check and see if we need to add something
$thing = $this->globals->getThing();
if ($thing instanceof ModuleEntity) {
$data = $thing->getData();
if ($data instanceof HtmlOverridableInterface) {
$overrides[] = $data;
}
}
// TODO: why was this change made with the favorite container fix?
$overrides = array_values(array_filter($overrides, function ($override) {
return ( ! empty($override));
}));
// set overrides from items
/** @var HtmlOverridableInterface $override */
foreach ($overrides as $override) {
$this->mergeHtmlOverride($override);
}
}
/**
* @param HtmlOverridableInterface $override
*/
private function mergeHtmlOverride(HtmlOverridableInterface $override = null)
{
if (empty($override)) {
return;
}
if ( ! empty($override->getBodyClass())) {
$this->globals->getAssetsOrganizer()->getBodyTag()
->prepend('class', $override->getBodyClass());
}
if ( ! empty($override->getHeadScripts())) {
$this->globals->getAssetsOrganizer()->getHtml()
->addInline($override->getHeadScripts(), null, 'head');
}
if ( ! empty($override->getTopScripts())) {
$this->globals->getAssetsOrganizer()->getHtml()
->addInline($override->getTopScripts(), null, 'top');
}
if ( ! empty($override->getBottomScripts())) {
$this->globals->getAssetsOrganizer()->getHtml()
->addInline($override->getBottomScripts(), null, 'bottom');
}
}
/**
* @param mixed $content
* @return string
*/
public function renderTheme($content = null)
{
// set the active
$this->generateActiveContent($content);
// TODO: if editing, ensure a base uri
if ($this->peekRealMode() === self::MODES__EDITOR) {
$this->peekGlobals()->getAssetsOrganizer()->getBaseTag()->set(
'href',
sprintf(
'%s://%s/%s',
$this->peekGlobals()->getDomain()->getCertificate() ? 'https' : 'http',
$this->peekGlobals()->getDomain()->getHost(),
implode('/', array_values(array_merge(
array_filter(
array_map(
function (Container $container) {
if (empty($container->getParent())) {
return null;
}
return $container->getSlug();
},
array_reverse($this->peekGlobals()->getContainers())
)
),
array('index')
)))
)
);
}
// add scripts and body tag class overrides set in admin panel
$this->mergeHtmlOverrides();
// preparations
$this->packageManager->mergeAssets(
$this->globals->getPackage(),
$this->globals->getAssetsOrganizer()
);
$this->themeManager->mergeAssets(
$this->globals->getTheme(),
$this->globals->getAssetsOrganizer()
);
$this->mergeAssets();
// just render the file
$rendered = $this->render(
'Theme',
[]
);
// need to clear out the active content, as this method will end the rendering chain
$this->activeContent = null;
// done
return $rendered;
}
/**
*
*/
private function mergeAssets()
{
// in all cases, we need to supply our global campussuite extension
$this->globals->getAssetsOrganizer()->getScripts()->addLinked(
sprintf(
'https://%s/bundles/cmscore/campussuite.js',
$this->globals->getDashboard()
),
null,
ScriptStructureBag::GROUPS__HEAD
);
// check for whether the mimic is in effect
// if so we need to add a class to the html that can be hooked
// into to fix theme topbar stuff with static headers in themes
if ($this->globals->getMimic() !== null) {
$this->globals->getAssetsOrganizer()->getHtmlTag()->append('class', 'admin-public');
}
// check for editor
if ($this->realMode === self::MODES__EDITOR) {
// add in the page editor css and js
$this->globals->getAssetsOrganizer()->getStyles()->addLinked(sprintf(
'https://%s/ui/css/page-builder.css',
$this->globals->getDashboard()
));
$this->globals->getAssetsOrganizer()->getScripts()->addInline(sprintf(
'window.head.load(\'%s\');',
$this->assetCatalog->locate('bootstrap::/dist/js/bootstrap.min.js')
));
}
}
/**
* @param string $twig
* @param array $parameters
* @return string
*/
private function render($twig, array $parameters)
{
static $dbgStack = [];
// fix the twig file
if (preg_match('/^[a-zA-Z0-9]+$/', $twig) === 1) {
$twig = sprintf(
'@CmsContent/Render/%s.html.twig',
$twig
);
}
// default some of the parameters
$parameters = array_merge($parameters, array(
'_renderer' => $this,
'_globals' => $this->globals,
'_doc' => $this->globals->getAssetsOrganizer(),
'_mode' => $this->mode,
'_realMode' => $this->realMode,
'_editing' => ($this->mode === self::MODES__EDITOR),
'_realEditing' => ($this->realMode === self::MODES__EDITOR),
'_editor' => ($this->mode === self::MODES__EDITOR || $this->realMode === self::MODES__EDITOR),
));
// render the template
array_push($dbgStack, sprintf(
'%s::%s',
(isset($parameters['_twig'])) ? $parameters['_twig'] : '???',
(isset($parameters['_htmlId'])) ? str_replace('cms-widget-', '', $parameters['_htmlId']) : '???'
));
$dbgevt = trim(sprintf(
'%s %s',
$twig,
implode(' ', $dbgStack)
));
$this->globals->debugStart($dbgevt);
$template = $this->twig->loadTemplate($twig);
$rendered = $template->render($parameters);
/*
$rendered = $this->container->get('twig')->render(
$twig,
$parameters
);
*/
$this->globals->debugStop($dbgevt);
array_pop($dbgStack);
// send back the result
return $rendered;
}
/**
* @param mixed $content
* @param mixed $default
* @return Inliner
* @throws \Exception
*/
private function normalizeContent($content, $default = [])
{
// make sure we are initialized
if ($this->mode === null) {
throw new \Exception();
}
// we must have something
if ($content === null) {
throw new \Exception();
}
// if false, then we don't want to use any content
if ($content === false) {
return new RegionInliner();
}
// if true, then we are defaulting the content
if ($content === true) {
switch (true) {
case $default instanceof OuterLayout:
$default = Transcoder::fromStorage($default->getContents());
break;
case $default instanceof InnerLayout:
$default = Transcoder::fromStorage($default->getContents());
break;
case $default instanceof Layout:
$default = $default->getEditorRegions();
break;
case is_array($default):
break;
default:
$default = [];
}
$content = $default;
}
// make a content inline
return new ContentInliner($content);
}
}