bkdotcom/PHPDebugConsole

View on GitHub
src/Debug/Route/Html.php

Summary

Maintainability
A
0 mins
Test Coverage
A
96%
<?php

/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2024 Brad Kent
 * @since     2.0
 */

namespace bdk\Debug\Route;

use bdk\Debug;
use bdk\Debug\AssetProviderInterface;
use bdk\Debug\Route\Html\ErrorSummary;
use bdk\Debug\Route\Html\Tabs;
use bdk\PubSub\Event;

/**
 * Output log as HTML
 */
class Html extends AbstractRoute
{
    /** @var ErrorSummary */
    protected $errorSummary;

    /** @var Tabs */
    protected $tabs;

    /** @var array<string,mixed> */
    protected $cfg = array();

    /** @var array{css:array,script:array} */
    private $assets = array(
        'css' => array(),
        'script' => array(),
    );

    /**
     * Constructor
     *
     * @param Debug $debug debug instance
     */
    public function __construct(Debug $debug)
    {
        parent::__construct($debug);
        $this->errorSummary = new ErrorSummary($this, $debug->errorHandler);
        $this->tabs = new Tabs($this);
        $this->cfg = array(
            'css' => '',            // additional "override" css
            'drawer' => true,
            'filepathCss' => __DIR__ . '/../css/Debug.css',
            'filepathScript' => __DIR__ . '/../js/Debug.jquery.min.js',
            'jqueryUrl' => '//ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js',
            'outputCss' => true,
            'outputScript' => true,
            'sidebar' => true,
            'tooltip' => true,
        );
        $this->dumper = $debug->getDump('html');
        $this->addAsset('css', $this->cfg['filepathCss']);
        $this->addAsset('script', $this->cfg['filepathScript']);
    }

    /**
     * Add/register css or javascript
     *
     * @param string $what  "css" or "script"
     * @param string $asset css, javascript, or filepath
     *
     * @return void
     */
    public function addAsset($what, $asset)
    {
        $this->assets[$what][] = $this->normalizeAssetPath($asset);
        $this->assets[$what] = \array_unique($this->assets[$what]);
    }

    /**
     * Get and register assets from passed provider
     *
     * @param AssetProviderInterface $assetProvider Asset provider
     *
     * @return void
     */
    public function addAssetProvider(AssetProviderInterface $assetProvider)
    {
        foreach ($assetProvider->getAssets() as $type => $assetsOfType) {
            foreach ((array) $assetsOfType as $asset) {
                $this->addAsset($type, $asset);
            }
        }
    }

    /**
     * Return all assets or return assets of specific type ("css" or "script")
     *
     * @param string $what (optional) specify "css" or "script"
     *
     * @return array
     */
    public function getAssets($what = null)
    {
        if ($what === null) {
            return $this->assets;
        }
        return isset($this->assets[$what])
            ? $this->assets[$what]
            : array();
    }

    /**
     * Return the log's CSS
     *
     * @return string
     */
    public function getCss()
    {
        return $this->buildAssetOutput($this->assets['css'])
            . $this->cfg['css'];
    }

    /**
     * Return the log's javascript
     *
     * @return string
     */
    public function getScript()
    {
        return $this->buildAssetOutput($this->assets['script']);
    }

    /**
     * Return the log as HTML
     *
     * @param Event|null $event Debug::EVENT_OUTPUT event object
     *
     * @return string|void
     */
    public function processLogEntries($event = null)
    {
        $this->debug->utility->assertType($event, 'bdk\PubSub\Event');

        if ($event['isTarget'] === false) {
            return;
        }
        $this->dumper->crateRaw = false;
        $this->data = $this->debug->data->get();
        // this could go in an extended processAlerts method
        $errorSummary = $this->errorSummary->build($this->debug->errorStats());
        if ($errorSummary['args'][0]) {
            \array_unshift($this->data['alerts'], $errorSummary);
        }
        $event['return'] .= $this->buildOutput();
        $this->data = array();
        $this->dumper->crateRaw = true;
    }

    /**
     * Add/register css or javascript
     *
     * @param string $what  "css" or "script"
     * @param string $asset css, javascript, or filepath
     *
     * @return bool
     */
    public function removeAsset($what, $asset)
    {
        $asset = $this->normalizeAssetPath($asset);
        foreach ($this->assets[$what] as $k => $v) {
            if ($v === $asset) {
                unset($this->assets[$what][$k]);
                $this->assets[$what] = \array_values($this->assets[$what]);
                return true;
            }
        }
        return false;
    }

    /**
     * UnRegister assets from passed provider
     *
     * @param AssetProviderInterface $assetProvider Asset provider
     *
     * @return void
     */
    public function removeAssetProvider(AssetProviderInterface $assetProvider)
    {
        foreach ($assetProvider->getAssets() as $type => $assetsOfType) {
            foreach ((array) $assetsOfType as $asset) {
                $this->removeAsset($type, $asset);
            }
        }
    }

    /**
     * Combine css or script assets into a single string
     *
     * @param array $assets array of assets (filepaths / strings)
     *
     * @return string
     */
    private function buildAssetOutput(array $assets)
    {
        $return = '';
        foreach ($assets as $asset) {
            // isFile "safely" checks if the value is an existing regular file
            if ($this->debug->utility->isFile($asset)) {
                $asset = \file_get_contents($asset);
            }
            if ($asset === false) {
                $asset = '/* PHPDebugConsole: unable to read file ' . $asset . ' */';
                $this->debug->alert('unable to read file ' . $asset);
            }
            $return .= $asset . "\n";
        }
        return $return;
    }

    /**
     * Build HTML output
     *
     * @return string
     */
    private function buildOutput()
    {
        $lftDefault = \strtr(\ini_get('xdebug.file_link_format'), array(
            '%f' => '%file',
            '%l' => '%line',
        ));
        $str = '<div' . $this->debug->html->buildAttribString(array(
            'class' => 'debug',
            // channel list gets built as log processed...  we'll str_replace this...
            'data-channel-name-root' => $this->channelNameRoot,
            'data-channels' => '{{channels}}',
            'data-options' => array(
                'drawer' => $this->cfg['drawer'],
                'linkFilesTemplateDefault' => $lftDefault ?: null,
                'tooltip' => $this->cfg['tooltip'],
            ),
        )) . ">\n"
            . $this->buildStyleTag()
            . $this->buildScriptTag()
            . $this->buildHeader()
            . $this->buildLoading()
            . $this->tabs->buildTabPanes()
            . '</div>' . "\n"; // close .debug

        $str = \preg_replace('#(<ul[^>]*>)\s+</ul>#', '$1</ul>', $str); // ugly, but want to be able to use :empty
        $str = \strtr($str, array(
            '{{channels}}' => \htmlspecialchars(\json_encode($this->buildChannelTree(), JSON_FORCE_OBJECT)),
        ));
        return $str;
    }

    /**
     * Build a tree of all channels that have been output
     *
     * @return array
     */
    protected function buildChannelTree()
    {
        $channels = $this->dumper->channels;
        $tree = array();
        $channelRoot = \reset($channels)->rootInstance;
        \ksort($channels, SORT_NATURAL | SORT_FLAG_CASE);
        \array_walk($channels, static function (Debug $channel, $name) use ($channels, $channelRoot, &$tree) {
            $ref = &$tree;
            $path = \explode('.', $name);
            foreach ($path as $i => $k) {
                // output may have only output general.foo
                //   we still need general
                $pathFq = \implode('.', \array_slice($path, 0, $i + 1));
                $channel = isset($channels[$pathFq])
                    ? $channels[$pathFq]
                    : $channelRoot->getChannel($pathFq);
                if (!isset($ref[$k])) {
                    $ref[$k] = array(
                        'channels' => array(),
                        'options' => array(
                            'icon' => $channel->getCfg('channelIcon', Debug::CONFIG_DEBUG),
                            'show' => $channel->getCfg('channelShow', Debug::CONFIG_DEBUG),
                        ),
                    );
                }
                $ref = &$ref[$k]['channels'];
            }
        });
        return $tree;
    }

    /**
     * Build <header> tag
     *
     * @return string
     */
    private function buildHeader()
    {
        return '<header class="debug-bar debug-menu-bar">'
            . 'PHPDebugConsole'
            . '<nav role="tablist"' . ($this->cfg['outputScript'] ? ' style="display:none;"' : '') . '>'
                . $this->tabs->buildTabList()
            . '</nav>'
            . '</header>' . "\n";
    }

    /**
     * Build "loading" spinner
     *
     * @return string
     */
    private function buildLoading()
    {
        return $this->cfg['outputScript']
            ? '<div class="loading">Loading <i class="fa fa-spinner fa-pulse fa-2x fa-fw" aria-hidden="true"></i></div>' . "\n"
            : '';
    }

    /**
     * Build <script> tag
     *
     * @return string
     */
    private function buildScriptTag()
    {
        if (!$this->cfg['outputScript']) {
            return '';
        }
        return '<script>window.jQuery || document.write(\'<script src="' . $this->cfg['jqueryUrl'] . '"><\/script>\')</script>' . "\n"
            . '<script>'
                . $this->getScript() . "\n"
            . '</script>' . "\n";
    }

    /**
     * Build <style> tag
     *
     * @return string
     */
    private function buildStyleTag()
    {
        if (!$this->cfg['outputCss']) {
            return '';
        }
        return '<style type="text/css">' . "\n"
                . $this->getCss() . "\n"
            . '</style>' . "\n";
    }

    /**
     * Convert "./" relative path to absolute
     *
     * @param string $asset css, javascript, or filepath
     *
     * @return string
     */
    private function normalizeAssetPath($asset)
    {
        return \preg_match('#[\r\n]#', $asset) !== 1
            ? \preg_replace('#^\./?#', __DIR__ . '/../', $asset) // single line... see if begins with "./""
            : $asset;
    }

    /**
     * {@inheritDoc}
     */
    protected function postSetCfg($cfg = array(), $prev = array())
    {
        $assetTypes = array(
            'filepathCss' => 'css',
            'filepathScript' => 'script',
        );
        $assetValues = \array_intersect_key($cfg, $assetTypes);
        foreach ($assetValues as $k => $v) {
            $type = $assetTypes[$k];
            $this->removeAsset($type, $prev[$k]);
            $this->addAsset($type, $v);
        }
    }
}