winter/modules/cms/twig/DebugExtension.php

613 lines
17 KiB
PHP

<?php namespace Cms\Twig;
use Twig\Template as TwigTemplate;
use Twig\Extension\AbstractExtension as TwigExtension;
use Twig\Environment as TwigEnvironment;
use Twig\TwigFunction as TwigSimpleFunction;
use Cms\Classes\Controller;
use Cms\Classes\ComponentBase;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Symfony\Component\VarDumper\Dumper\HtmlDumper;
use Symfony\Component\VarDumper\Cloner\VarCloner;
use Winter\Storm\Database\Model;
class DebugExtension extends TwigExtension
{
const PAGE_CAPTION = 'Page variables';
const ARRAY_CAPTION = 'Array variables';
const OBJECT_CAPTION = 'Object variables';
const COMPONENT_CAPTION = 'Component variables';
/**
* @var \Cms\Classes\Controller A reference to the CMS controller.
*/
protected $controller;
/**
* @var integer Helper for rendering table row styles.
*/
protected $zebra = 1;
/**
* @var boolean If no variable is passed, true.
*/
protected $variablePrefix = false;
/**
* @var array Collection of method/property comments.
*/
protected $commentMap = [];
/**
* @var array Blocked object methods that should not be included in the dump.
*/
protected $blockMethods = [
'componentDetails',
'defineProperties',
'getPropertyOptions',
'offsetExists',
'offsetGet',
'offsetSet',
'offsetUnset'
];
/**
* Creates the extension instance.
* @param \Cms\Classes\Controller $controller The CMS controller object.
*/
public function __construct(Controller $controller)
{
$this->controller = $controller;
}
/**
* Returns a list of global functions to add to the existing list.
* @return array An array of global functions
*/
public function getFunctions()
{
return [
new TwigSimpleFunction('dump', [$this, 'runDump'], [
'is_safe' => ['html'],
'needs_context' => true,
'needs_environment' => true
]),
];
}
/**
* Processes the dump variables, if none is supplied, all the twig
* template variables are used
* @param TwigEnvironment $env
* @param array $context
* @return string
*/
public function runDump(TwigEnvironment $env, $context)
{
if (!$env->isDebug()) {
return;
}
$result = '';
$count = func_num_args();
if ($count == 2) {
$this->variablePrefix = true;
$vars = [];
foreach ($context as $key => $value) {
if (!$value instanceof TwigTemplate) {
$vars[$key] = $value;
}
}
$result .= $this->dump($vars, static::PAGE_CAPTION);
}
else {
$this->variablePrefix = false;
for ($i = 2; $i < $count; $i++) {
$var = func_get_arg($i);
if ($var instanceof ComponentBase) {
$caption = [static::COMPONENT_CAPTION, get_class($var)];
}
elseif (is_array($var)) {
$caption = static::ARRAY_CAPTION;
}
elseif (is_object($var)) {
$caption = [static::OBJECT_CAPTION, get_class($var)];
}
else {
$caption = [static::OBJECT_CAPTION, gettype($var)];
}
$result .= $this->dump($var, $caption);
}
}
return $result;
}
/**
* Dump information about a variable
* @param mixed $variables Variable to dump
* @param mixed $caption Caption [and subcaption] of the dump
* @return void
*/
public function dump($variables = null, $caption = null)
{
$this->commentMap = [];
$this->zebra = 1;
$info = [];
if (!is_array($variables)) {
if ($variables instanceof Paginator) {
$variables = $this->paginatorToArray($variables);
}
elseif (is_object($variables)) {
$variables = $this->objectToArray($variables);
}
else {
$variables = [$variables];
}
}
$output = [];
$output[] = '<table>';
if ($caption) {
$output[] = $this->makeTableHeader($caption);
}
$output[] = '<tbody>';
foreach ($variables as $key => $item) {
$output[] = $this->makeTableRow($key, $item);
}
$output[] = '</tbody>';
$output[] = '</table>';
$html = implode(PHP_EOL, $output);
return '<pre style="' . $this->getContainerCss() . '">' . $html . '</pre>';
}
/**
* Builds the HTML used for the table header.
* @param mixed $caption Caption [and subcaption] of the dump
* @return string
*/
protected function makeTableHeader($caption)
{
if (is_array($caption)) {
list($caption, $subcaption) = $caption;
}
$output = [];
$output[] = '<thead>';
$output[] = '<tr>';
$output[] = '<th colspan="3" style="'.$this->getHeaderCss().'">';
$output[] = $caption;
if (isset($subcaption)) {
$output[] = '<div style="'.$this->getSubheaderCss().'">'.$subcaption.'</div>';
}
$output[] = '</th>';
$output[] = '</tr>';
$output[] = '</thead>';
return implode(PHP_EOL, $output);
}
/**
* Builds the HTML used for each table row.
* @param mixed $key
* @param mixed $variable
* @return string
*/
protected function makeTableRow($key, $variable)
{
$this->zebra = $this->zebra ? 0 : 1;
$css = $this->getDataCss($variable);
$output = [];
$output[] = '<tr>';
$output[] = '<td style="'.$css.';cursor:pointer" onclick="'.$this->evalToggleDumpOnClick().'">'.$this->evalKeyLabel($key).'</td>';
$output[] = '<td style="'.$css.'">'.$this->evalVarLabel($variable).'</td>';
$output[] = '<td style="'.$css.'">'.$this->evalVarDesc($variable, $key).'</td>';
$output[] = '</tr>';
$output[] = '<tr>';
$output[] = '<td colspan="3">'.$this->evalVarDump($variable).'</td>';
$output[] = '</tr>';
return implode(PHP_EOL, $output);
}
/**
* Builds JavaScript for toggling the dump container
* @return string
*/
protected function evalToggleDumpOnClick()
{
$output = "var d=this.parentElement.nextElementSibling.getElementsByTagName('div')[0];";
$output .= "d.style.display=='none'?d.style.display='block':d.style.display='none'";
return $output;
}
/**
* Dumps a variable using HTML Dumper, wrapped in a hidden DIV element.
* @param mixed $variable
* @return string
*/
protected function evalVarDump($variable)
{
$dumper = new HtmlDumper;
$cloner = new VarCloner;
$output = '<div style="display:none">';
$output .= $dumper->dump($cloner->cloneVar($variable), true);
$output .= '</div>';
return $output;
}
/**
* Returns a variable name as HTML friendly.
* @param string $key
* @return string
*/
protected function evalKeyLabel($key)
{
if ($this->variablePrefix === true) {
$output = '{{ <span>%s</span> }}';
}
elseif (is_array($this->variablePrefix)) {
$prefix = implode('.', $this->variablePrefix);
$output = '{{ <span>'.$prefix.'.%s</span> }}';
}
elseif ($this->variablePrefix) {
$output = '{{ <span>'.$this->variablePrefix.'.%s</span> }}';
}
else {
$output = '%s';
}
return sprintf($output, $key);
}
/**
* Evaluate the variable description
* @param mixed $variable
* @return string
*/
protected function evalVarLabel($variable)
{
$type = $this->getType($variable);
switch ($type) {
case 'object':
return $this->evalObjLabel($variable);
case 'array':
return $type . '('.count($variable).')';
default:
return $type;
}
}
/**
* Evaluate an object type for label
* @param object $variable
* @return string
*/
protected function getType($variable)
{
$type = gettype($variable);
if ($type == 'string' && substr($variable, 0, 12) == '___METHOD___') {
return 'method';
}
return $type;
}
/**
* Evaluate an object type for label
* @param object $variable
* @return string
*/
protected function evalObjLabel($variable)
{
$class = get_class($variable);
$label = class_basename($variable);
if ($variable instanceof ComponentBase) {
$label = '<strong>Component</strong>';
}
elseif ($variable instanceof Collection) {
$label = 'Collection('.$variable->count().')';
}
elseif ($variable instanceof Paginator) {
$label = 'Paged Collection('.$variable->count().')';
}
elseif ($variable instanceof Model) {
$label = 'Model';
}
return '<abbr title="'.e($class).'">'.$label.'</abbr>';
}
/**
* Evaluate the variable description
* @param mixed $variable
* @return string
*/
protected function evalVarDesc($variable, $key)
{
$type = $this->getType($variable);
if ($type == 'method') {
return $this->evalMethodDesc($variable);
}
if (isset($this->commentMap[$key])) {
return $this->commentMap[$key];
}
if ($type == 'array') {
return $this->evalArrDesc($variable);
}
if ($type == 'object') {
return $this->evalObjDesc($variable);
}
return '';
}
/**
* Evaluate an method type for description
* @param object $variable
* @return string
*/
protected function evalMethodDesc($variable)
{
$parts = explode('|', $variable);
if (count($parts) < 2) {
return null;
}
$method = $parts[1];
return $this->commentMap[$method] ?? null;
}
/**
* Evaluate an array type for description
* @param array $variable
* @return string
*/
protected function evalArrDesc($variable)
{
$output = [];
foreach ($variable as $key => $value) {
$output[] = '<abbr title="'.e(gettype($value)).'">'.$key.'</abbr>';
}
return implode(', ', $output);
}
/**
* Evaluate an object type for description
* @param array $variable
* @return string
*/
protected function evalObjDesc($variable)
{
$output = [];
if ($variable instanceof ComponentBase) {
$details = $variable->componentDetails();
$output[] = '<abbr title="'.array_get($details, 'description').'">';
$output[] = array_get($details, 'name');
$output[] = '</abbr>';
}
return implode('', $output);
}
//
// Object helpers
//
/**
* Returns default comment information for a paginator object.
* @param Illuminate\Pagination\Paginator $paginator
* @return array
*/
protected function paginatorToArray(Paginator $paginator)
{
$this->commentMap = [
'links()' => 'Renders links for navigating the collection',
'currentPage' => 'Get the current page for the request.',
'lastPage' => 'Get the last page that should be available.',
'perPage' => 'Get the number of items to be displayed per page.',
'total' => 'Get the total number of items in the complete collection.',
'from' => 'Get the number of the first item on the paginator.',
'to' => 'Get the number of the last item on the paginator.',
'count' => 'Returns the number of items in this collection',
];
return [
'links' => '___METHOD___|links()',
'currentPage' => '___METHOD___|currentPage',
'lastPage' => '___METHOD___|lastPage',
'perPage' => '___METHOD___|perPage',
'total' => '___METHOD___|total',
'from' => '___METHOD___|from',
'to' => '___METHOD___|to',
'count' => '___METHOD___|count',
];
}
/**
* Returns a map of an object as an array, containing methods and properties.
* @param mixed $object
* @return array
*/
protected function objectToArray($object)
{
$class = get_class($object);
$info = new \ReflectionClass($object);
$this->commentMap[$class] = [];
$methods = [];
foreach ($info->getMethods() as $method) {
if (!$method->isPublic()) {
continue; // Only public
}
if ($method->class != $class) {
continue; // Only locals
}
$name = $method->getName();
if (in_array($name, $this->blockMethods)) {
continue; // Blocked methods
}
if (preg_match('/^on[A-Z]{1}[\w+]*$/', $name)) {
continue; // AJAX methods
}
if (preg_match('/^get[A-Z]{1}[\w+]*Options$/', $name)) {
continue; // getSomethingOptions
}
if (substr($name, 0, 1) == '_') {
continue; // Magic/hidden method
}
$name .= '()';
$methods[$name] = '___METHOD___|'.$name;
$this->commentMap[$name] = $this->evalDocBlock($method);
}
$vars = [];
foreach ($info->getProperties() as $property) {
if ($property->isStatic()) {
continue; // Only non-static
}
if (!$property->isPublic()) {
continue; // Only public
}
if ($property->class != $class) {
continue; // Only locals
}
$name = $property->getName();
$vars[$name] = $object->{$name};
$this->commentMap[$name] = $this->evalDocBlock($property);
}
return $methods + $vars;
}
/**
* Extracts the comment from a DocBlock
* @param ReflectionClass $reflectionObj
* @return string
*/
protected function evalDocBlock($reflectionObj)
{
$comment = $reflectionObj->getDocComment();
$comment = substr($comment, 3, -2);
$parts = explode('@', $comment);
$comment = array_shift($parts);
$comment = trim(trim($comment), '*');
$comment = implode(' ', array_map('trim', explode('*', $comment)));
return $comment;
}
//
// Style helpers
//
/**
* Get the CSS string for the output data
* @param mixed $variable
* @return string
*/
protected function getDataCss($variable)
{
$css = [
'padding' => '7px',
'background-color' => $this->zebra ? '#D8D9DB' : '#FFF',
'color' => '#405261',
];
$type = gettype($variable);
if ($type == 'NULL') {
$css['color'] = '#999';
}
return $this->arrayToCss($css);
}
/**
* Get the CSS string for the output container
* @return string
*/
protected function getContainerCss()
{
return $this->arrayToCss([
'background-color' => '#F3F3F3',
'border' => '1px solid #bbb',
'border-radius' => '4px',
'font-size' => '12px',
'line-height' => '18px',
'margin' => '20px',
'padding' => '7px',
'display' => 'inline-block',
]);
}
/**
* Get the CSS string for the output header
* @return string
*/
protected function getHeaderCss()
{
return $this->arrayToCss([
'font-size' => '18px',
'font-weight' => 'normal',
'margin' => '0',
'padding' => '10px',
'background-color' => '#7B8892',
'color' => '#FFF',
]);
}
/**
* Get the CSS string for the output subheader
* @return string
*/
protected function getSubheaderCss()
{
return $this->arrayToCss([
'font-size' => '12px',
'font-weight' => 'normal',
'font-style' => 'italic',
'margin' => '0',
'padding' => '0',
'background-color' => '#7B8892',
'color' => '#FFF',
]);
}
/**
* Convert a key/value pair array into a CSS string
* @param array $rules List of rules to process
* @return string
*/
protected function arrayToCss(array $rules)
{
$strings = [];
foreach ($rules as $key => $value) {
$strings[] = $key . ': ' . $value;
}
return implode('; ', $strings);
}
}