bkdotcom/PHPDebugConsole

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

Summary

Maintainability
A
0 mins
Test Coverage
A
92%
<?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.3.1
 */

namespace bdk\Debug\Utility;

use bdk\Debug\Utility\Php as PhpUtil;
use Closure;
use UnexpectedValueException;

/**
 * Html utilities
 */
class Html
{
    /** @var int whether to decode data attribute */
    const PARSE_ATTRIB_DATA = 1;
    /** @var int whether to explode class attribute */
    const PARSE_ATTRIB_CLASS = 2;
    /** @var int whether to cast numeric attribute value */
    const PARSE_ATTRIB_NUMERIC = 4;

    /**
     * self closing / empty / void html tags
     *
     * Not including 'command' (obsolete) and 'keygen' (deprecated),
     *
     * @var array
     */
    public static $tagsEmpty = [
        'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr',
    ];

    /**
     * The presence of these attributes = true / absence = false
     *
     * Value not necessary, but may be written as key="key"  (ie autofocus="autofocus" )
     *
     * @var array
     *
     * @link https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
     */
    public static $htmlBoolAttr = [
        // GLOBAL
        'hidden', 'itemscope',

        // FORM / INPUT
        'autofocus', 'checked', 'disabled', 'formnovalidate', 'multiple', 'novalidate', 'readonly', 'required', 'selected',

        // AUDIO / VIDEO / TRACK
        'autoplay', 'controls', 'default', 'loop', 'muted', 'playsinline',

        'open', // <details> & <dialog>
        'reversed', // <ol>
        'frameborder', // <iframe> removed from draft
        'ismap', // <img>
        'truespeed', // <marquee>
        'typemustmatch', // <object> removed from draft
        'async', 'defer', 'nomodule',  // <script>
        'scoped',   // <style> removed from draft

        // OBSOLETE / DEPRECATED / NEVER-A-THING
        'allowfullscreen',     // <iframe> - legacy: redefined as allow="fullscreen"
        'allowpaymentrequest', // <iframe> - legacy: redefined as allow="payment"
        'compact',  // <dir> and <ol>
        'nohref',   // <area>
        'noresize', // <frame>
        'noshade',  // <hr>
        'nowrap',   // dt, dd, td, th
        'scrolling',// <iframe>
        'seamless', // <iframe> - removed from draft
        'sortable', // <table> - removed from draft
    ];

    /**
     * Enum attributes that behave like bool, but have "true" / "false" value
     *
     * @var array
     */
    public static $htmlBoolAttrEnum = [
        'autocapitalize', // on|off (other values also accepted)
        'autocomplete', // on|off (other values also accepted)
        'translate', // yes|no

        // "true"/"false" :
        'aria-checked', 'aria-expanded', 'aria-grabbed', 'aria-hidden', 'aria-pressed', 'aria-selected',
        'contenteditable', 'draggable', 'spellcheck',
    ];

    /**
     * Build attribute string
     *
     * Attributes will be sorted by name
     * class & style attributes may be provided as arrays
     * data-* attributes will be json-encoded (if non-string)
     * non data attribs with null value will not be output
     *
     * If a string is passed, it will be parsed and rebuilt
     *
     * @param array<string,mixed>|string $attribs key/values
     *
     * @return string
     * @see    https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
     */
    public static function buildAttribString($attribs)
    {
        if (\is_string($attribs)) {
            $attribs = static::parseAttribString($attribs);
        }
        $attribPairs = array();
        foreach ($attribs as $name => $value) {
            // buildAttribNameValue updates $name by reference
            $nameVal = HtmlBuild::buildAttribNameValue($name, $value);
            $attribPairs[$name] = $nameVal;
        }
        $attribPairs = \array_filter($attribPairs, 'strlen');
        \ksort($attribPairs);
        return \rtrim(' ' . \implode(' ', $attribPairs));
    }

    /**
     * Build an html tag
     *
     * @param string                     $tagName   tag name (ie "div" or "input")
     * @param array<string,mixed>|string $attribs   key/value attributes
     * @param string|Closure             $innerhtml inner HTML if applicable
     *
     * @return string
     *
     * @throws UnexpectedValueException
     */
    public static function buildTag($tagName, $attribs = array(), $innerhtml = '')
    {
        $tagName = \strtolower($tagName);
        $attribStr = self::buildAttribString($attribs);
        if ($innerhtml instanceof Closure) {
            $innerhtml = \call_user_func($innerhtml);
            if (\is_string($innerhtml) === false) {
                throw new UnexpectedValueException(\sprintf(
                    'Innerhtml closure should return string.  Got %s',
                    PhpUtil::getDebugType($innerhtml)
                ));
            }
        }
        return \in_array($tagName, self::$tagsEmpty, true)
            ? '<' . $tagName . $attribStr . ' />'
            : '<' . $tagName . $attribStr . '>' . $innerhtml . '</' . $tagName . '>';
    }

    /**
     * Parse string of attributes into a key => value array
     *
     * @param string         $str     string to parse
     * @param int|null|false $options bitmask of PARSE_ATTRIB_x flags
     *
     * @return array<string,mixed>
     */
    public static function parseAttribString($str, $options = null)
    {
        if ($options === false) {
            $options = 0;
        } elseif ($options === null) {
            $options = self::PARSE_ATTRIB_CLASS | self::PARSE_ATTRIB_DATA | self::PARSE_ATTRIB_NUMERIC;
        }
        $attribs = array();
        if ($options & self::PARSE_ATTRIB_CLASS) {
            // if "parsing" class attribute, always include it
            $attribs['class'] = array();
        }
        $regexAttribs = '/
            \b(?P<name>[\w\-]+)\b
            (?: \s*=\s* (?:
                (["\'])(?P<valQuoted>.*?)\\2
                | (?P<valUnquoted>\S+)
            ))?/xs';
        \preg_match_all($regexAttribs, $str, $matches);
        $values = \array_replace($matches['valQuoted'], \array_filter($matches['valUnquoted'], 'strlen'));
        foreach ($matches[1] as $i => $attribName) {
            $attribName = \strtolower($attribName);
            $attribs[$attribName] = HtmlParse::parseAttribValue($attribName, $values[$i], $options);
        }
        \ksort($attribs);
        return $attribs;
    }

    /**
     * Parse HTML/XML tag
     *
     * returns array(
     *    'tagname' => string
     *    'attribs' => array
     *    'innerhtml' => string | null
     * )
     *
     * @param string   $tag     html tag to parse
     * @param int|null $options bitmask of self::PARSE_ATTRIB_x flags
     *
     * @return array|false
     */
    public static function parseTag($tag, $options = null)
    {
        $regexTag = '#<(?P<tagname>[^\s<>]+)(?P<attributes>[^<>]*)>(?P<innerhtml>.*)</\\1>#is';
        $regexTag2 = '#^<(?:/\s*)?(?P<tagname>[^\s<>]+)(?P<attributes>[^<>]*?)/?>$#s'; // self-closing tag or closing tag
        $tag = \trim($tag);
        if (\preg_match($regexTag, $tag, $matches)) {
            // phpcs:ignore SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
            return array(
                'tagname' => \strtolower($matches['tagname']),
                'attribs' => self::parseAttribString($matches['attributes'], $options),
                'innerhtml' => $matches['innerhtml'],
            );
        }
        if (\preg_match($regexTag2, $tag, $matches)) {
            // phpcs:ignore SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys.IncorrectKeyOrder
            return array(
                'tagname' => \strtolower($matches['tagname']),
                'attribs' => self::parseAttribString($matches['attributes'], $options),
                'innerhtml' => null,
            );
        }
        return false;
    }

    /**
     * Sanitize html
     *
     * @param string $html html snippet
     *
     * @return string sanitized html
     */
    public static function sanitize($html)
    {
        return HtmlSanitize::sanitize($html);
    }

    /**
     * Remove "invalid" characters from id attribute
     *
     * @param string|null $id Id value
     *
     * @return string|null
     */
    public static function sanitizeId($id)
    {
        if ($id === null) {
            return $id;
        }
        $id = \preg_replace('/^[^A-Za-z]+/', '', $id);
        // note that ":" and "." are  allowed chars but not practical... removing
        $id = \preg_replace('/[^a-zA-Z0-9_\-]+/', '_', $id);
        $id = \preg_replace('/_+/', '_', $id);
        return $id;
    }
}