<?php
/**
 * Jingga
 *
 * PHP Version 8.2
 *
 * @package    phpOMS\Utils\Parser\Markdown
 * @copyright  Original & extra license Emanuil Rusev, erusev.com (MIT)
 * @copyright  Extended license Benjamin Hoegh (MIT)
 * @copyright  Extreme license doowzs (MIT)
 * @license    This version: OMS License 2.2
 * @version    1.0.0
 * @link       https://jingga.app
 */
declare(strict_types=1);

namespace phpOMS\Utils\Parser\Markdown;

use phpOMS\Uri\UriFactory;

/**
 * Markdown parser class.
 *
 * @package    phpOMS\Utils\Parser\Markdown
 * @copyright  Original & extra license Emanuil Rusev, erusev.com (MIT)
 * @copyright  Extended license Benjamin Hoegh (MIT)
 * @copyright  Extreme license doowzs (MIT)
 * @license    This version: OMS License 2.2
 * @link       https://jingga.app
 * @see        https://github.com/erusev/parsedown
 * @see        https://github.com/erusev/parsedown-extra
 * @see        https://github.com/BenjaminHoegh/ParsedownExtended
 * @see        https://github.com/doowzs/parsedown-extreme
 * @since      1.0.0
 *
 * @todo Add special markdown content
 *  1. Calendar (own widget)
 *  2. Event (own widget)
 *  3. Tasks (own widget)
 *  4. Vote/Survey (own widget)
 *  5. Website link/embed widgets (facebook, linkedIn, twitter, ...)
 *  6. User/Supplier/Client/Employee (own widget, should make use of schema)
 *  7. Address (own widget, should make use of schema)
 *  8. Contact (own widget, should make use of schema)
 *  9. Item (own widget, should make use of schema)
 * 10. Progress radial
 * 11. Timeline horizontal/vertical/matrix
 * 12. Tabs horizontal/vertical
 * 13. Checklist (own widget)
 * 14. Gallery
 * 15. Form (own widget)
 * https://github.com/Karaka-Management/phpOMS/issues/290
 */
class Markdown
{
    /**
     * Parsedown version
     *
     * @var string
     * @since 1.0.0
     */
    public const VERSION = '2.0.0';

    /**
     * Special markdown characters
     *
     * @var string[]
     * @since 1.0.0
     */
    protected array $specialCharacters = [
        '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '?', '"', "'", '<',
    ];

    /**
     * Regexes for html strong
     *
     * @var array<string, string>
     * @since 1.0.0
     */
    protected array $strongRegex = [
        '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s',
    ];

    /**
     * Regexes for html underline
     *
     * @var array<string, string>
     * @since 1.0.0
     */
    protected array $underlineRegex = [
        '_' => '/^[_]{2}((?:\\\\\_|[^_]|[_][^_]_+[_])+?)[_]{2}(?![_])/s',
    ];

    /**
     * Regexes for html emphasizes
     *
     * @var array<string, string>
     * @since 1.0.0
     */
    protected array $emRegex = [
        '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
        '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
    ];

    /**
     * Regex for html attributes
     *
     * @var string
     * @since 1.0.0
     */
    protected string $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+';

    /**
     * Regex for for classes and ids
     *
     * @var string
     * @since 1.0.0
     */
    protected string $regexAttribute = '(?:[#.][-\w]+[ ]*)';

    /**
     * Elements without closing
     *
     * @var string[]
     * @since 1.0.0
     */
    protected array $voidElements = [
        'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
    ];

    /**
     * Text elements
     *
     * @var string[]
     * @since 1.0.0
     */
    protected array $textLevelElements = [
        'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
        'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
        'i', 'rp', 'del', 'code',          'strike', 'marquee',
        'q', 'rt', 'ins', 'font',          'strong',
        's', 'tt', 'kbd', 'mark',
        'u', 'xm', 'sub', 'nobr',
                   'sup', 'ruby',
                   'var', 'span',
                   'wbr', 'time',
    ];

    /**
     * Inline special characters (not block elements)
     *
     * @var array<string, string[]>
     * @since 1.0.0
     */
    protected array $inlineTypes = [
        '!'  => ['Image'],
        '*'  => ['Emphasis'],
        '_'  => ['Emphasis'],
        '&'  => ['SpecialCharacter'],
        '['  => ['FootnoteMarker', 'Link'],
        ':'  => ['Url'],
        '<'  => ['UrlTag', 'EmailTag', 'Markup'],
        '`'  => ['Code'],
        '~'  => ['Strikethrough'],
        '\\' => ['EscapeSequence'],
    ];

    /**
     * Inline special characters (not block elements) (see $inlineTypes)
     *
     * @var string
     * @since 1.0.0
     */
    protected string $inlineMarkerList = '!*_&[:<`~';

    /**
     * Uses strict mode?
     *
     * Less forgiving with regards to formatting.
     *
     * @var bool
     * @since 1.0.0
     */
    public bool $strictMode = false;

    /**
     * Uses safe mode (true -> no html allowed)?
     *
     * Important for parsing or sanitizing html.
     *
     * @var bool
     * @since 1.0.0
     */
    public bool $safeMode = false;

    /**
     * Urls are always handled as links
     *
     * @var bool
     * @since 1.0.0
     */
    public bool $urlsLinked = true;

    /**
     * Should html get escaped?
     *
     * @var bool
     * @since 1.0.0
     */
    public bool $markupEscaped = false;

    /**
     * Replaces new lines with <br> in inline elements
     *
     * true  -> replaces new line with br
     * false -> replaces double whitespace followed with new line with br
     *
     * @var bool
     * @since 1.0.0
     */
    public bool $breaksEnabled = false;

    /**
     * Save link prefixes
     *
     * @var string[]
     * @since 1.0.0
     */
    protected array $safeLinksWhitelist = [
        'http://',
        'https://',
        'ftp://',
        'ftps://',
        'mailto:',
        'tel:',
        'data:image/png;base64,',
        'data:image/gif;base64,',
        'data:image/jpeg;base64,',
        'irc:',
        'ircs:',
        'git:',
        'ssh:',
        'news:',
        'steam:',
    ];

    /**
     * Block special characters
     *
     * @var array<int|string, string[]>
     * @since 1.0.0
     */
    protected array $blockTypes = [
        '#' => ['Header'],
        '*' => ['Rule', 'List', 'Abbreviation'],
        '+' => ['List'],
        '-' => ['SetextHeader', 'Table', 'Rule', 'List'],
        '0' => ['List'],
        '1' => ['List'],
        '2' => ['List'],
        '3' => ['List'],
        '4' => ['List'],
        '5' => ['List'],
        '6' => ['List'],
        '7' => ['List'],
        '8' => ['List'],
        '9' => ['List'],
        ':' => ['Table', 'DefinitionList'],
        '<' => ['Comment', 'Markup'],
        '=' => ['SetextHeader'],
        '>' => ['Quote'],
        '[' => ['Footnote', 'Reference'],
        '_' => ['Rule'],
        '`' => ['FencedCode'],
        '|' => ['Table'],
    ];

    /**
     * Unmarked block types
     *
     * @var string[]
     * @since 1.0.0
     */
    protected array $unmarkedBlockTypes = [
        'Code',
    ];

    /**
     * Is continuable
     *
     * @var string[]
     * @since 1.0.0
     */
    private const CONTINUABLE = [
        'Code', 'Comment', 'FencedCode', 'List', 'Quote', 'Table', 'Math', 'Spoiler', 'Checkbox', 'Footnote', 'DefinitionList', 'Markup',
    ];

    /**
     * Is completable
     *
     * @var string[]
     * @since 1.0.0
     */
    private const COMPLETABLE = [
        'Math', 'Spoiler', 'Table', 'Checkbox', 'Footnote', 'Markup', 'Code', 'FencedCode', 'List',
    ];

    /**
     * Parsing options
     *
     * @var array
     * @since 1.0.0
     */
    private array $options = [];

    /**
     * Definition data
     *
     * E.g. abbreviations, footnotes
     *
     * @var array
     * @since 1.0.0
     */
    protected array $definitionData = [];

    // TOC: start
    /**
     * Table of content id
     *
     * @var string
     * @since 1.0.0
     */
    public string $idToc = 'toc';

    /**
     * TOC array after parsing headers
     *
     * @var array{}|array{text:string, id:string, level:string}
     * @since 1.0.0
     */
    protected array $contentsListArray = [];

    /**
     * TOC string after parsing headers
     *
     * @var string
     * @since 1.0.0
     */
    protected string $contentsListString = '';

    /**
     * First head level
     *
     * @var int
     * @since 1.0.0
     */
    protected int $firstHeadLevel = 0;

    /**
     * Is header blacklist (for table of contents/TOC) initialized
     *
     * @var bool
     * @since 1.0.0
     */
    protected bool $isBlacklistInitialized = false;

    /**
     * Header duplicates (same header text)
     *
     * @var array<string, int>
     * @since 1.0.0
     */
    protected array $anchorDuplicates = [];
    // TOC: end

    /**
     * Footnote count
     *
     * @var int
     * @since 1.0.0
     */
    private int $footnoteCount = 0;

    /**
     * Current abbreviation
     *
     * @var string
     * @since 1.0.0
     */
    private string $currentAbbreviation;

    /**
     * Current abbreviation meaning
     *
     * @var string
     * @since 1.0.0
     */
    private string $currentMeaning;

    /**
     * Instances
     *
     * @var array<string, self>
     * @since 1.0.0
     */
    private static $instances = [];

    /**
     * Clean up state
     *
     * @return void
     *
     * @since 1.0.0
     */
    public function clean() : void
    {
        $this->definitionData      = [];
        $this->contentsListArray   = [];
        $this->contentsListString  = '';
        $this->firstHeadLevel      = 0;
        $this->anchorDuplicates    = [];
        $this->footnoteCount       = 0;
        $this->currentAbbreviation = '';
        $this->currentMeaning      = '';
    }

    /**
     * Create instance for static use
     *
     * @param string $name Instance name
     *
     * @return self
     *
     * @since 1.0.0
     */
    public static function getInstance(string $name = 'default') : self
    {
        if (isset(self::$instances[$name])) {
            $obj = self::$instances[$name];
            $obj->clean();

            return self::$instances[$name];
        }

        $instance = new self();

        self::$instances[$name] = $instance;

        return $instance;
    }

    /**
     * Constructor.
     *
     * @param array $params Parameters
     *
     * @since 1.0.0
     */
    public function __construct(array $params = [])
    {
        $this->options        = $params;
        $this->options['toc'] = $this->options['toc'] ?? false;

        // Marks
        if ($this->options['mark'] ?? true) {
            $this->inlineTypes['='][] = 'Mark';
            $this->inlineMarkerList .= '=';
        }

        // Keystrokes
        if ($this->options['keystrokes'] ?? true) {
            $this->inlineTypes['['][] = 'Keystrokes';
        }

        // Spoiler
        if ($this->options['spoiler'] ?? false) {
            $this->inlineTypes['>'][] = 'Spoiler';
            $this->inlineMarkerList .= '>';
        }

        // Inline Math
        if ($this->options['math'] ?? false) {
            $this->inlineTypes['\\'][] = 'Math';
            $this->inlineTypes['$'][]  = 'Math';
            $this->inlineMarkerList .= '$';
        }

        // Superscript
        if ($this->options['sup'] ?? false) {
            $this->inlineTypes['^'][] = 'Superscript';
            $this->inlineMarkerList .= '^';
        }

        // Subscript
        if ($this->options['sub'] ?? false) {
            $this->inlineTypes['~'][] = 'Subscript';
        }

        // Emojis
        if ($this->options['emojis'] ?? true) {
            $this->inlineTypes[':'][] = 'Emojis';
        }

        // Typographer
        if ($this->options['typographer'] ?? false) {
            $this->inlineTypes['('][] = 'Typographer';
            $this->inlineMarkerList .= '(';
            $this->inlineTypes['.'][] = 'Typographer';
            $this->inlineMarkerList .= '.';
            $this->inlineTypes['+'][] = 'Typographer';
            $this->inlineMarkerList .= '+';
            $this->inlineTypes['!'][] = 'Typographer';
            $this->inlineTypes['?'][] = 'Typographer';
            $this->inlineMarkerList .= '?';
        }

        // Block Math
        if ($this->options['math'] ?? false) {
            $this->blockTypes['\\'][] = 'Math';
            $this->blockTypes['$'][]  = 'Math';
        }

        // Block Spoiler
        if ($this->options['spoiler'] ?? false) {
            $this->blockTypes['?'][] = 'Spoiler';
        }

        // Checkbox
        if ($this->options['lists']['checkbox'] ?? true) {
            $this->blockTypes['['][] = 'Checkbox';
        }

        // Embedding
        if ($this->options['embedding'] ?? false) {
            $this->inlineTypes['['][] = 'Embedding';
        }

        // Map
        if ($this->options['map'] ?? false) {
            $this->inlineTypes['['][] = 'Map';
        }

        // Address
        if ($this->options['address'] ?? false) {
            $this->inlineTypes['['][] = 'Address';
        }

        // Contact
        if ($this->options['contact'] ?? false) {
            $this->inlineTypes['['][] = 'Contact';
        }

        // Progress
        if ($this->options['progress'] ?? false) {
            $this->inlineTypes['['][] = 'Progress';
        }

        // Escaping needs to happen at the end
        $this->inlineMarkerList .= '\\';
    }

    /**
     * Parses the given markdown string to a HTML
     *
     * @param string $text Markdown text to parse
     *
     * @return string
     *
     * @since 1.0.0
     */
    public static function parse(string $text) : string
    {
        $parsedown = self::getInstance();

        return $parsedown->text($text);
    }

    /**
     * Parses the given markdown string to a HTML string but it ignores ToC
     *
     * @param string $text Markdown text to parse
     *
     * @return string
     *
     * @since 1.0.0
     */
    public function body(string $text) : string
    {
        $text = $this->encodeToCTagToHash($text);  // Escapes ToC tag temporary

        $elements = $this->textElements($text);
        $html     = $this->elements($elements);
        $html     = \trim($html, "\n");

        // Merge consecutive dl elements
        $html = \preg_replace('/<\/dl>\s+<dl>\s+/', '', $html) ?? '';

        // Add footnotes
        if (isset($this->definitionData['Footnote'])) {
            $element = $this->buildFootnoteElement();
            $html .= "\n" . $this->element($element);
        }

        return $this->decodeToCTagFromHash($html); // Unescape the ToC tag
    }

    /**
     * Parses markdown string to HTML and also the "[toc]" tag as well.
     *
     * @param string $text Markdown text to parse
     *
     * @return string
     *
     * @since 1.0.0
     */
    public function text(string $text) : string
    {
        // Parses the markdown text except the ToC tag. This also searches
        // the list of contents and available to get from "contentsList()"
        // method.
        $html = $this->body($text);

        if (isset($this->options['toc']) && $this->options['toc'] === false) {
            return $html;
        }

        // Handle toc
        $tagOrigin = $this->options['toc']['set_toc_tag'] ?? '[toc]';

        if (\strpos($text, $tagOrigin) === false) {
            return $html;
        }

        $tocData = $this->contentsList();
        $needle  = '<p>' . $tagOrigin . '</p>';
        $replace = '<div id="' . $this->idToc . '">' . $tocData . '</div>';

        return \str_replace($needle, $replace, $html);
    }

    /**
     * Returns the parsed ToC.
     *
     * @param string $typeReturn Type of the return format. "html" or "json".
     *
     * @return string HTML/JSON string of ToC
     *
     * @since 1.0.0
     */
    public function contentsList($typeReturn = 'html') : string
    {
        if (\strtolower($typeReturn) === 'json') {
            $json = \json_encode($this->contentsListArray);

            return $json === false ? '' : $json;
        }

        $result = '';
        if (!empty($this->contentsListString)) {
            // Parses the ToC list in markdown to HTML
            $result = $this->body($this->contentsListString);
        }

        return $result;
    }

    /**
     * Handle inline code
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineCode(array $excerpt) : ?array
    {
        if (!($this->options['code']['inline'] ?? true)
            || !($this->options['code'] ?? true)
        ) {
            return null;
        }

        $marker = $excerpt['text'][0];

        if (\preg_match(
                '/^([' . $marker . ']++)[ ]*+(.+?)[ ]*+(?<![' . $marker . '])\1(?!' . $marker . ')/s',
                $excerpt['text'], $matches
            ) !== 1
        ) {
            return null;
        }

        $text = $matches[2];
        $text = \preg_replace('/[ ]*+\n/', ' ', $text);

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name' => 'code',
                'text' => $text,
            ],
        ];
    }

    /**
     * Handle inline email
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineEmailTag(array $excerpt) : ?array
    {
        if (!($this->options['links'] ?? true)
            || !($this->options['links']['email_links'] ?? true)
        ) {
            return null;
        }

        $hostnameLabel   = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
        $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@' . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*';

        if (\strpos($excerpt['text'], '>') === false
            || \preg_match('/^<((mailto:)?' . $commonMarkEmail . ')>/i', $excerpt['text'], $matches) !== 1
        ) {
            return null;
        }

        $url = UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[1]));

        if (!isset($matches[2])) {
            $url = "mailto:{$url}";
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'       => 'a',
                'text'       => $matches[1],
                'attributes' => [
                    'href' => $url,
                ],
            ],
        ];
    }

    /**
     * Inline emphasis
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineEmphasis(array $excerpt) : ?array
    {
        if (!($this->options['emphasis'] ?? true)
            || !isset($excerpt['text'][1])
        ) {
            return null;
        }

        $marker = $excerpt['text'][0];

        if ($excerpt['text'][1] === $marker
            && isset($this->strongRegex[$marker]) && \preg_match($this->strongRegex[$marker], $excerpt['text'], $matches)
        ) {
            $emphasis = 'strong';
        } elseif ($excerpt['text'][1] === $marker
            && isset($this->underlineRegex[$marker]) && \preg_match($this->underlineRegex[$marker], $excerpt['text'], $matches)
        ) {
            $emphasis = 'u';
        } elseif (\preg_match($this->emRegex[$marker], $excerpt['text'], $matches)) {
            $emphasis = 'em';
        } else {
            return null;
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'    => $emphasis,
                'handler' => [
                    'function'    => 'lineElements',
                    'argument'    => $matches[1],
                    'destination' => 'elements',
                ],
            ],
        ];
    }

    /**
     * Handle image
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineImage(array $excerpt) : ?array
    {
        if (!($this->options['images'] ?? true)
            || !\str_starts_with($excerpt['text'], '![')
        ) {
            return null;
        }

        $excerpt['text'] = \substr($excerpt['text'], 1);
        $link            = $this->inlineLink($excerpt);

        if ($link === null) {
            return null;
        }

        $inline = [
            'extent'  => $link['extent'] + 1,
            'element' => [
                'name'       => 'img',
                'attributes' => [
                    'src' => $link['element']['attributes']['href'],
                    'alt' => $link['element']['handler']['argument'],
                ],
                'autobreak' => true,
            ],
        ];

        $inline['element']['attributes'] += $link['element']['attributes'];

        unset($inline['element']['attributes']['href']);

        return $inline;
    }

    /**
     * Handle link
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function inlineLink(array $excerpt) : ?array
    {
        if (!($this->options['links'] ?? true)) {
            return null;
        }

        $link = $this->inlineLinkParent($excerpt);
        if ($link === null) {
            return null;
        }

        $remainder = \substr($excerpt['text'], $link['extent']);

        if (\preg_match('/^[ ]*{(' . $this->regexAttribute . '+)}/', $remainder, $matches)) {
            $link['extent']                += \strlen($matches[0]);
            $link['element']['attributes'] += $this->parseAttributeData($matches[1]);
        }

        return $link;
    }

    /**
     * Handle markup
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineMarkup(array $excerpt) : ?array
    {
        if (!($this->options['markup'] ?? true)
            || $this->markupEscaped || $this->safeMode || \strpos($excerpt['text'], '>') === false
        ) {
            return null;
        }

        $matches = [];
        if (($excerpt['text'][1] === '/' && \preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $excerpt['text'], $matches))
            || ($excerpt['text'][1] === '!' && \preg_match('/^<!---?[^>-](?:-?+[^-])*-->/s', $excerpt['text'], $matches))
            || ($excerpt['text'][1] !== ' ' && \preg_match('/^<\w[\w-]*+(?:[ ]*+' . $this->regexHtmlAttribute . ')*+[ ]*+\/?>/s', $excerpt['text'], $matches))
        ) {
            return [
                'extent'  => \strlen($matches[0]),
                'element' => ['rawHtml' => $matches[0]],
            ];
        }

        return null;
    }

    /**
     * Handle strikethrough
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineStrikethrough(array $excerpt) : ?array
    {
        if (!($this->options['strikethroughs'] ?? true)
            || !\str_starts_with($excerpt['text'], '~~')
            || \preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $excerpt['text'], $matches) !== 1
        ) {
            return null;
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'    => 'del',
                'handler' => [
                    'function'    => 'lineElements',
                    'argument'    => $matches[1],
                    'destination' => 'elements',
                ],
            ],
        ];
    }

    /**
     * Handle url
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, position:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineUrl(array $excerpt) : ?array
    {
        if (!($this->options['links'] ?? true)
            || !$this->urlsLinked || !\str_starts_with($excerpt['text'], '://')
            || \strpos($excerpt['context'], 'http') === false
            || \preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $excerpt['context'], $matches, \PREG_OFFSET_CAPTURE) !== 1
        ) {
            return null;
        }

        $url = UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[0][0]));

        return [
            'extent'   => \strlen($matches[0][0]),
            'position' => $matches[0][1],
            'element'  => [
                'name'       => 'a',
                'text'       => $url,
                'attributes' => [
                    'href' => $url,
                ],
            ],
        ];
    }

    /**
     * Handle url
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineUrlTag(array $excerpt) : ?array
    {
        if (!($this->options['links'] ?? true)
            || \strpos($excerpt['text'], '>') === false
            || \preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $excerpt['text'], $matches) !== 1
        ) {
            return null;
        }

        $url = UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[1]));

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'       => 'a',
                'text'       => $url,
                'attributes' => [
                    'href' => $url,
                ],
            ],
        ];
    }

    /**
     * Handle emojis
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineEmojis(array $excerpt) : ?array
    {
        if (\preg_match('/^(:)([^: ]*?)(:)/', $excerpt['text'], $matches) !== 1) {
            return null;
        }

        $emojiMap = [
            ':smile:'                           => '😄', ':laughing:' => '😆', ':blush:' => '😊', ':smiley:' => '😃',
            ':relaxed:'                         => '☺️', ':smirk:' => '😏', ':heart_eyes:' => '😍', ':kissing_heart:' => '😘',
            ':kissing_closed_eyes:'             => '😚', ':flushed:' => '😳', ':relieved:' => '😌', ':satisfied:' => '😆',
            ':grin:'                            => '😁', ':wink:' => '😉', ':stuck_out_tongue_winking_eye:' => '😜', ':stuck_out_tongue_closed_eyes:' => '😝',
            ':grinning:'                        => '😀', ':kissing:' => '😗', ':kissing_smiling_eyes:' => '😙', ':stuck_out_tongue:' => '😛',
            ':sleeping:'                        => '😴', ':worried:' => '😟', ':frowning:' => '😦', ':anguished:' => '😧',
            ':open_mouth:'                      => '😮', ':grimacing:' => '😬', ':confused:' => '😕', ':hushed:' => '😯',
            ':expressionless:'                  => '😑', ':unamused:' => '😒', ':sweat_smile:' => '😅', ':sweat:' => '😓',
            ':disappointed_relieved:'           => '😥', ':weary:' => '😩', ':pensive:' => '😔', ':disappointed:' => '😞',
            ':confounded:'                      => '😖', ':fearful:' => '😨', ':cold_sweat:' => '😰', ':persevere:' => '😣',
            ':cry:'                             => '😢', ':sob:' => '😭', ':joy:' => '😂', ':astonished:' => '😲',
            ':scream:'                          => '😱', ':tired_face:' => '😫', ':angry:' => '😠', ':rage:' => '😡',
            ':triumph:'                         => '😤', ':sleepy:' => '😪', ':yum:' => '😋', ':mask:' => '😷',
            ':sunglasses:'                      => '😎', ':dizzy_face:' => '😵', ':imp:' => '👿', ':smiling_imp:' => '😈',
            ':neutral_face:'                    => '😐', ':no_mouth:' => '😶', ':innocent:' => '😇', ':alien:' => '👽',
            ':yellow_heart:'                    => '💛', ':blue_heart:' => '💙', ':purple_heart:' => '💜', ':heart:' => '❤️',
            ':green_heart:'                     => '💚', ':broken_heart:' => '💔', ':heartbeat:' => '💓', ':heartpulse:' => '💗',
            ':two_hearts:'                      => '💕', ':revolving_hearts:' => '💞', ':cupid:' => '💘', ':sparkling_heart:' => '💖',
            ':sparkles:'                        => '✨', ':star:' => '⭐️', ':star2:' => '🌟', ':dizzy:' => '💫',
            ':boom:'                            => '💥', ':collision:' => '💥', ':anger:' => '💢', ':exclamation:' => '❗️',
            ':question:'                        => '❓', ':grey_exclamation:' => '❕', ':grey_question:' => '❔', ':zzz:' => '💤',
            ':dash:'                            => '💨', ':sweat_drops:' => '💦', ':notes:' => '🎶', ':musical_note:' => '🎵',
            ':fire:'                            => '🔥', ':hankey:' => '💩', ':poop:' => '💩', ':shit:' => '💩',
            ':+1:'                              => '👍', ':thumbsup:' => '👍', ':-1:' => '👎', ':thumbsdown:' => '👎',
            ':ok_hand:'                         => '👌', ':punch:' => '👊', ':facepunch:' => '👊', ':fist:' => '✊',
            ':v:'                               => '✌️', ':wave:' => '👋', ':hand:' => '✋', ':raised_hand:' => '✋',
            ':open_hands:'                      => '👐', ':point_up:' => '☝️', ':point_down:' => '👇', ':point_left:' => '👈',
            ':point_right:'                     => '👉', ':raised_hands:' => '🙌', ':pray:' => '🙏', ':point_up_2:' => '👆',
            ':clap:'                            => '👏', ':muscle:' => '💪', ':metal:' => '🤘', ':fu:' => '🖕',
            ':walking:'                         => '🚶', ':runner:' => '🏃', ':running:' => '🏃', ':couple:' => '👫',
            ':family:'                          => '👪', ':two_men_holding_hands:' => '👬', ':two_women_holding_hands:' => '👭', ':dancer:' => '💃',
            ':dancers:'                         => '👯', ':ok_woman:' => '🙆', ':no_good:' => '🙅', ':information_desk_person:' => '💁',
            ':raising_hand:'                    => '🙋', ':bride_with_veil:' => '👰', ':person_with_pouting_face:' => '🙎', ':person_frowning:' => '🙍',
            ':bow:'                             => '🙇', ':couple_with_heart:' => '💑', ':massage:' => '💆', ':haircut:' => '💇',
            ':nail_care:'                       => '💅', ':boy:' => '👦', ':girl:' => '👧', ':woman:' => '👩',
            ':man:'                             => '👨', ':baby:' => '👶', ':older_woman:' => '👵', ':older_man:' => '👴',
            ':person_with_blond_hair:'          => '👱', ':man_with_gua_pi_mao:' => '👲', ':man_with_turban:' => '👳', ':construction_worker:' => '👷',
            ':cop:'                             => '👮', ':angel:' => '👼', ':princess:' => '👸', ':smiley_cat:' => '😺',
            ':smile_cat:'                       => '😸', ':heart_eyes_cat:' => '😻', ':kissing_cat:' => '😽', ':smirk_cat:' => '😼',
            ':scream_cat:'                      => '🙀', ':crying_cat_face:' => '😿', ':joy_cat:' => '😹', ':pouting_cat:' => '😾',
            ':japanese_ogre:'                   => '👹', ':japanese_goblin:' => '👺', ':see_no_evil:' => '🙈', ':hear_no_evil:' => '🙉',
            ':speak_no_evil:'                   => '🙊', ':guardsman:' => '💂', ':skull:' => '💀', ':feet:' => '🐾',
            ':lips:'                            => '👄', ':kiss:' => '💋', ':droplet:' => '💧', ':ear:' => '👂',
            ':eyes:'                            => '👀', ':nose:' => '👃', ':tongue:' => '👅', ':love_letter:' => '💌',
            ':bust_in_silhouette:'              => '👤', ':busts_in_silhouette:' => '👥', ':speech_balloon:' => '💬', ':thought_balloon:' => '💭',
            ':sunny:'                           => '☀️', ':umbrella:' => '☔️', ':cloud:' => '☁️', ':snowflake:' => '❄️',
            ':snowman:'                         => '⛄️', ':zap:' => '⚡️', ':cyclone:' => '🌀', ':foggy:' => '🌁',
            ':ocean:'                           => '🌊', ':cat:' => '🐱', ':dog:' => '🐶', ':mouse:' => '🐭',
            ':hamster:'                         => '🐹', ':rabbit:' => '🐰', ':wolf:' => '🐺', ':frog:' => '🐸',
            ':tiger:'                           => '🐯', ':koala:' => '🐨', ':bear:' => '🐻', ':pig:' => '🐷',
            ':pig_nose:'                        => '🐽', ':cow:' => '🐮', ':boar:' => '🐗', ':monkey_face:' => '🐵',
            ':monkey:'                          => '🐒', ':horse:' => '🐴', ':racehorse:' => '🐎', ':camel:' => '🐫',
            ':sheep:'                           => '🐑', ':elephant:' => '🐘', ':panda_face:' => '🐼', ':snake:' => '🐍',
            ':bird:'                            => '🐦', ':baby_chick:' => '🐤', ':hatched_chick:' => '🐥', ':hatching_chick:' => '🐣',
            ':chicken:'                         => '🐔', ':penguin:' => '🐧', ':turtle:' => '🐢', ':bug:' => '🐛',
            ':honeybee:'                        => '🐝', ':ant:' => '🐜', ':beetle:' => '🐞', ':snail:' => '🐌',
            ':octopus:'                         => '🐙', ':tropical_fish:' => '🐠', ':fish:' => '🐟', ':whale:' => '🐳',
            ':whale2:'                          => '🐋', ':dolphin:' => '🐬', ':cow2:' => '🐄', ':ram:' => '🐏',
            ':rat:'                             => '🐀', ':water_buffalo:' => '🐃', ':tiger2:' => '🐅', ':rabbit2:' => '🐇',
            ':dragon:'                          => '🐉', ':goat:' => '🐐', ':rooster:' => '🐓', ':dog2:' => '🐕',
            ':pig2:'                            => '🐖', ':mouse2:' => '🐁', ':ox:' => '🐂', ':dragon_face:' => '🐲',
            ':blowfish:'                        => '🐡', ':crocodile:' => '🐊', ':dromedary_camel:' => '🐪', ':leopard:' => '🐆',
            ':cat2:'                            => '🐈', ':poodle:' => '🐩', ':crab' => '🦀', ':paw_prints:' => '🐾', ':bouquet:' => '💐',
            ':cherry_blossom:'                  => '🌸', ':tulip:' => '🌷', ':four_leaf_clover:' => '🍀', ':rose:' => '🌹',
            ':sunflower:'                       => '🌻', ':hibiscus:' => '🌺', ':maple_leaf:' => '🍁', ':leaves:' => '🍃',
            ':fallen_leaf:'                     => '🍂', ':herb:' => '🌿', ':mushroom:' => '🍄', ':cactus:' => '🌵',
            ':palm_tree:'                       => '🌴', ':evergreen_tree:' => '🌲', ':deciduous_tree:' => '🌳', ':chestnut:' => '🌰',
            ':seedling:'                        => '🌱', ':blossom:' => '🌼', ':ear_of_rice:' => '🌾', ':shell:' => '🐚',
            ':globe_with_meridians:'            => '🌐', ':sun_with_face:' => '🌞', ':full_moon_with_face:' => '🌝', ':new_moon_with_face:' => '🌚',
            ':new_moon:'                        => '🌑', ':waxing_crescent_moon:' => '🌒', ':first_quarter_moon:' => '🌓', ':waxing_gibbous_moon:' => '🌔',
            ':full_moon:'                       => '🌕', ':waning_gibbous_moon:' => '🌖', ':last_quarter_moon:' => '🌗', ':waning_crescent_moon:' => '🌘',
            ':last_quarter_moon_with_face:'     => '🌜', ':first_quarter_moon_with_face:' => '🌛', ':moon:' => '🌔', ':earth_africa:' => '🌍',
            ':earth_americas:'                  => '🌎', ':earth_asia:' => '🌏', ':volcano:' => '🌋', ':milky_way:' => '🌌',
            ':partly_sunny:'                    => '⛅️', ':bamboo:' => '🎍', ':gift_heart:' => '💝', ':dolls:' => '🎎',
            ':school_satchel:'                  => '🎒', ':mortar_board:' => '🎓', ':flags:' => '🎏', ':fireworks:' => '🎆',
            ':sparkler:'                        => '🎇', ':wind_chime:' => '🎐', ':rice_scene:' => '🎑', ':jack_o_lantern:' => '🎃',
            ':ghost:'                           => '👻', ':santa:' => '🎅', ':christmas_tree:' => '🎄', ':gift:' => '🎁',
            ':bell:'                            => '🔔', ':no_bell:' => '🔕', ':tanabata_tree:' => '🎋', ':tada:' => '🎉',
            ':confetti_ball:'                   => '🎊', ':balloon:' => '🎈', ':crystal_ball:' => '🔮', ':cd:' => '💿',
            ':dvd:'                             => '📀', ':floppy_disk:' => '💾', ':camera:' => '📷', ':video_camera:' => '📹',
            ':movie_camera:'                    => '🎥', ':computer:' => '💻', ':tv:' => '📺', ':iphone:' => '📱',
            ':phone:'                           => '☎️', ':telephone:' => '☎️', ':telephone_receiver:' => '📞', ':pager:' => '📟',
            ':fax:'                             => '📠', ':minidisc:' => '💽', ':vhs:' => '📼', ':sound:' => '🔉',
            ':speaker:'                         => '🔈', ':mute:' => '🔇', ':loudspeaker:' => '📢', ':mega:' => '📣',
            ':hourglass:'                       => '⌛️', ':hourglass_flowing_sand:' => '⏳', ':alarm_clock:' => '⏰', ':watch:' => '⌚️',
            ':radio:'                           => '📻', ':satellite:' => '📡', ':loop:' => '➿', ':mag:' => '🔍',
            ':mag_right:'                       => '🔎', ':unlock:' => '🔓', ':lock:' => '🔒', ':lock_with_ink_pen:' => '🔏',
            ':closed_lock_with_key:'            => '🔐', ':key:' => '🔑', ':bulb:' => '💡', ':flashlight:' => '🔦',
            ':high_brightness:'                 => '🔆', ':low_brightness:' => '🔅', ':electric_plug:' => '🔌', ':battery:' => '🔋',
            ':calling:'                         => '📲', ':email:' => '✉️', ':mailbox:' => '📫', ':postbox:' => '📮',
            ':bath:'                            => '🛀', ':bathtub:' => '🛁', ':shower:' => '🚿', ':toilet:' => '🚽',
            ':wrench:'                          => '🔧', ':nut_and_bolt:' => '🔩', ':hammer:' => '🔨', ':seat:' => '💺',
            ':moneybag:'                        => '💰', ':yen:' => '💴', ':dollar:' => '💵', ':pound:' => '💷',
            ':euro:'                            => '💶', ':credit_card:' => '💳', ':money_with_wings:' => '💸', ':e-mail:' => '📧',
            ':inbox_tray:'                      => '📥', ':outbox_tray:' => '📤', ':envelope:' => '✉️', ':incoming_envelope:' => '📨',
            ':postal_horn:'                     => '📯', ':mailbox_closed:' => '📪', ':mailbox_with_mail:' => '📬', ':mailbox_with_no_mail:' => '📭',
            ':door:'                            => '🚪', ':smoking:' => '🚬', ':bomb:' => '💣', ':gun:' => '🔫',
            ':hocho:'                           => '🔪', ':pill:' => '💊', ':syringe:' => '💉', ':page_facing_up:' => '📄',
            ':page_with_curl:'                  => '📃', ':bookmark_tabs:' => '📑', ':bar_chart:' => '📊', ':chart_with_upwards_trend:' => '📈',
            ':chart_with_downwards_trend:'      => '📉', ':scroll:' => '📜', ':clipboard:' => '📋', ':calendar:' => '📆',
            ':date:'                            => '📅', ':card_index:' => '📇', ':file_folder:' => '📁', ':open_file_folder:' => '📂',
            ':scissors:'                        => '✂️', ':pushpin:' => '📌', ':paperclip:' => '📎', ':black_nib:' => '✒️',
            ':pencil2:'                         => '✏️', ':straight_ruler:' => '📏', ':triangular_ruler:' => '📐', ':closed_book:' => '📕',
            ':green_book:'                      => '📗', ':blue_book:' => '📘', ':orange_book:' => '📙', ':notebook:' => '📓',
            ':notebook_with_decorative_cover:'  => '📔', ':ledger:' => '📒', ':books:' => '📚', ':bookmark:' => '🔖',
            ':name_badge:'                      => '📛', ':microscope:' => '🔬', ':telescope:' => '🔭', ':newspaper:' => '📰',
            ':football:'                        => '🏈', ':basketball:' => '🏀', ':soccer:' => '⚽️', ':baseball:' => '⚾️',
            ':tennis:'                          => '🎾', ':8ball:' => '🎱', ':rugby_football:' => '🏉', ':bowling:' => '🎳',
            ':golf:'                            => '⛳️', ':mountain_bicyclist:' => '🚵', ':bicyclist:' => '🚴', ':horse_racing:' => '🏇',
            ':snowboarder:'                     => '🏂', ':swimmer:' => '🏊', ':surfer:' => '🏄', ':ski:' => '🎿',
            ':spades:'                          => '♠️', ':hearts:' => '♥️', ':clubs:' => '♣️', ':diamonds:' => '♦️',
            ':gem:'                             => '💎', ':ring:' => '💍', ':trophy:' => '🏆', ':musical_score:' => '🎼',
            ':musical_keyboard:'                => '🎹', ':violin:' => '🎻', ':space_invader:' => '👾', ':video_game:' => '🎮',
            ':black_joker:'                     => '🃏', ':flower_playing_cards:' => '🎴', ':game_die:' => '🎲', ':dart:' => '🎯',
            ':mahjong:'                         => '🀄️', ':clapper:' => '🎬', ':memo:' => '📝', ':pencil:' => '📝',
            ':book:'                            => '📖', ':art:' => '🎨', ':microphone:' => '🎤', ':headphones:' => '🎧',
            ':trumpet:'                         => '🎺', ':saxophone:' => '🎷', ':guitar:' => '🎸', ':shoe:' => '👞',
            ':sandal:'                          => '👡', ':high_heel:' => '👠', ':lipstick:' => '💄', ':boot:' => '👢',
            ':shirt:'                           => '👕', ':tshirt:' => '👕', ':necktie:' => '👔', ':womans_clothes:' => '👚',
            ':dress:'                           => '👗', ':running_shirt_with_sash:' => '🎽', ':jeans:' => '👖', ':kimono:' => '👘',
            ':bikini:'                          => '👙', ':ribbon:' => '🎀', ':tophat:' => '🎩', ':crown:' => '👑',
            ':womans_hat:'                      => '👒', ':mans_shoe:' => '👞', ':closed_umbrella:' => '🌂', ':briefcase:' => '💼',
            ':handbag:'                         => '👜', ':pouch:' => '👝', ':purse:' => '👛', ':eyeglasses:' => '👓',
            ':fishing_pole_and_fish:'           => '🎣', ':coffee:' => '☕️', ':tea:' => '🍵', ':sake:' => '🍶',
            ':baby_bottle:'                     => '🍼', ':beer:' => '🍺', ':beers:' => '🍻', ':cocktail:' => '🍸',
            ':tropical_drink:'                  => '🍹', ':wine_glass:' => '🍷', ':fork_and_knife:' => '🍴', ':pizza:' => '🍕',
            ':hamburger:'                       => '🍔', ':fries:' => '🍟', ':poultry_leg:' => '🍗', ':meat_on_bone:' => '🍖',
            ':spaghetti:'                       => '🍝', ':curry:' => '🍛', ':fried_shrimp:' => '🍤', ':bento:' => '🍱',
            ':sushi:'                           => '🍣', ':fish_cake:' => '🍥', ':rice_ball:' => '🍙', ':rice_cracker:' => '🍘',
            ':rice:'                            => '🍚', ':ramen:' => '🍜', ':stew:' => '🍲', ':oden:' => '🍢',
            ':dango:'                           => '🍡', ':egg:' => '🥚', ':bread:' => '🍞', ':doughnut:' => '🍩',
            ':custard:'                         => '🍮', ':icecream:' => '🍦', ':ice_cream:' => '🍨', ':shaved_ice:' => '🍧',
            ':birthday:'                        => '🎂', ':cake:' => '🍰', ':cookie:' => '🍪', ':chocolate_bar:' => '🍫',
            ':candy:'                           => '🍬', ':lollipop:' => '🍭', ':honey_pot:' => '🍯', ':apple:' => '🍎',
            ':green_apple:'                     => '🍏', ':tangerine:' => '🍊', ':lemon:' => '🍋', ':cherries:' => '🍒',
            ':grapes:'                          => '🍇', ':watermelon:' => '🍉', ':strawberry:' => '🍓', ':peach:' => '🍑',
            ':melon:'                           => '🍈', ':banana:' => '🍌', ':pear:' => '🍐', ':pineapple:' => '🍍',
            ':sweet_potato:'                    => '🍠', ':eggplant:' => '🍆', ':tomato:' => '🍅', ':corn:' => '🌽',
            ':house:'                           => '🏠', ':house_with_garden:' => '🏡', ':school:' => '🏫', ':office:' => '🏢',
            ':post_office:'                     => '🏣', ':hospital:' => '🏥', ':bank:' => '🏦', ':convenience_store:' => '🏪',
            ':love_hotel:'                      => '🏩', ':hotel:' => '🏨', ':wedding:' => '💒', ':church:' => '⛪️',
            ':department_store:'                => '🏬', ':european_post_office:' => '🏤', ':city_sunrise:' => '🌇', ':city_sunset:' => '🌆',
            ':japanese_castle:'                 => '🏯', ':european_castle:' => '🏰', ':tent:' => '⛺️', ':factory:' => '🏭',
            ':tokyo_tower:'                     => '🗼', ':japan:' => '🗾', ':mount_fuji:' => '🗻', ':sunrise_over_mountains:' => '🌄',
            ':sunrise:'                         => '🌅', ':stars:' => '🌠', ':statue_of_liberty:' => '🗽', ':bridge_at_night:' => '🌉',
            ':carousel_horse:'                  => '🎠', ':rainbow:' => '🌈', ':ferris_wheel:' => '🎡', ':fountain:' => '⛲️',
            ':roller_coaster:'                  => '🎢', ':ship:' => '🚢', ':speedboat:' => '🚤', ':boat:' => '⛵️',
            ':sailboat:'                        => '⛵️', ':rowboat:' => '🚣', ':anchor:' => '⚓️', ':rocket:' => '🚀',
            ':airplane:'                        => '✈️', ':helicopter:' => '🚁', ':steam_locomotive:' => '🚂', ':tram:' => '🚊',
            ':mountain_railway:'                => '🚞', ':bike:' => '🚲', ':aerial_tramway:' => '🚡', ':suspension_railway:' => '🚟',
            ':mountain_cableway:'               => '🚠', ':tractor:' => '🚜', ':blue_car:' => '🚙', ':oncoming_automobile:' => '🚘',
            ':car:'                             => '🚗', ':red_car:' => '🚗', ':taxi:' => '🚕', ':oncoming_taxi:' => '🚖',
            ':articulated_lorry:'               => '🚛', ':bus:' => '🚌', ':oncoming_bus:' => '🚍', ':rotating_light:' => '🚨',
            ':police_car:'                      => '🚓', ':oncoming_police_car:' => '🚔', ':fire_engine:' => '🚒', ':ambulance:' => '🚑',
            ':minibus:'                         => '🚐', ':truck:' => '🚚', ':train:' => '🚋', ':station:' => '🚉',
            ':train2:'                          => '🚆', ':bullettrain_front:' => '🚅', ':bullettrain_side:' => '🚄', ':light_rail:' => '🚈',
            ':monorail:'                        => '🚝', ':railway_car:' => '🚃', ':trolleybus:' => '🚎', ':ticket:' => '🎫',
            ':fuelpump:'                        => '⛽️', ':vertical_traffic_light:' => '🚦', ':traffic_light:' => '🚥', ':warning:' => '⚠️',
            ':construction:'                    => '🚧', ':beginner:' => '🔰', ':atm:' => '🏧', ':slot_machine:' => '🎰',
            ':busstop:'                         => '🚏', ':barber:' => '💈', ':hotsprings:' => '♨️', ':checkered_flag:' => '🏁',
            ':crossed_flags:'                   => '🎌', ':izakaya_lantern:' => '🏮', ':moyai:' => '🗿', ':circus_tent:' => '🎪',
            ':performing_arts:'                 => '🎭', ':round_pushpin:' => '📍', ':triangular_flag_on_post:' => '🚩', ':jp:' => '🇯🇵',
            ':kr:'                              => '🇰🇷', ':cn:' => '🇨🇳', ':us:' => '🇺🇸', ':fr:' => '🇫🇷',
            ':es:'                              => '🇪🇸', ':it:' => '🇮🇹', ':ru:' => '🇷🇺', ':gb:' => '🇬🇧',
            ':uk:'                              => '🇬🇧', ':de:' => '🇩🇪', ':one:' => '1️⃣', ':two:' => '2️⃣',
            ':three:'                           => '3️⃣', ':four:' => '4️⃣', ':five:' => '5️⃣', ':six:' => '6️⃣',
            ':seven:'                           => '7️⃣', ':eight:' => '8️⃣', ':nine:' => '9️⃣', ':keycap_ten:' => '🔟',
            ':1234:'                            => '🔢', ':zero:' => '0️⃣', ':hash:' => '#️⃣', ':symbols:' => '🔣',
            ':arrow_backward:'                  => '◀️', ':arrow_down:' => '⬇️', ':arrow_forward:' => '▶️', ':arrow_left:' => '⬅️',
            ':capital_abcd:'                    => '🔠', ':abcd:' => '🔡', ':abc:' => '🔤', ':arrow_lower_left:' => '↙️',
            ':arrow_lower_right:'               => '↘️', ':arrow_right:' => '➡️', ':arrow_up:' => '⬆️', ':arrow_upper_left:' => '↖️',
            ':arrow_upper_right:'               => '↗️', ':arrow_double_down:' => '⏬', ':arrow_double_up:' => '⏫', ':arrow_down_small:' => '🔽',
            ':arrow_heading_down:'              => '⤵️', ':arrow_heading_up:' => '⤴️', ':leftwards_arrow_with_hook:' => '↩️', ':arrow_right_hook:' => '↪️',
            ':left_right_arrow:'                => '↔️', ':arrow_up_down:' => '↕️', ':arrow_up_small:' => '🔼', ':arrows_clockwise:' => '🔃',
            ':arrows_counterclockwise:'         => '🔄', ':rewind:' => '⏪', ':fast_forward:' => '⏩', ':information_source:' => 'ℹ️',
            ':ok:'                              => '🆗', ':twisted_rightwards_arrows:' => '🔀', ':repeat:' => '🔁', ':repeat_one:' => '🔂',
            ':new:'                             => '🆕', ':top:' => '🔝', ':up:' => '🆙', ':cool:' => '🆒',
            ':free:'                            => '🆓', ':ng:' => '🆖', ':cinema:' => '🎦', ':koko:' => '🈁',
            ':signal_strength:'                 => '📶', ':u5272:' => '🈹', ':u5408:' => '🈴', ':u55b6:' => '🈺',
            ':u6307:'                           => '🈯️', ':u6708:' => '🈷️', ':u6709:' => '🈶', ':u6e80:' => '🈵',
            ':u7121:'                           => '🈚️', ':u7533:' => '🈸', ':u7a7a:' => '🈳', ':u7981:' => '🈲',
            ':sa:'                              => '🈂️', ':restroom:' => '🚻', ':mens:' => '🚹', ':womens:' => '🚺',
            ':baby_symbol:'                     => '🚼', ':no_smoking:' => '🚭', ':parking:' => '🅿️', ':wheelchair:' => '♿️',
            ':metro:'                           => '🚇', ':baggage_claim:' => '🛄', ':accept:' => '🉑', ':wc:' => '🚾',
            ':potable_water:'                   => '🚰', ':put_litter_in_its_place:' => '🚮', ':secret:' => '㊙️', ':congratulations:' => '㊗️',
            ':m:'                               => 'Ⓜ️', ':passport_control:' => '🛂', ':left_luggage:' => '🛅', ':customs:' => '🛃',
            ':ideograph_advantage:'             => '🉐', ':cl:' => '🆑', ':sos:' => '🆘', ':id:' => '🆔',
            ':no_entry_sign:'                   => '🚫', ':underage:' => '🔞', ':no_mobile_phones:' => '📵', ':do_not_litter:' => '🚯',
            ':non-potable_water:'               => '🚱', ':no_bicycles:' => '🚳', ':no_pedestrians:' => '🚷', ':children_crossing:' => '🚸',
            ':no_entry:'                        => '⛔️', ':eight_spoked_asterisk:' => '✳️', ':eight_pointed_black_star:' => '✴️', ':heart_decoration:' => '💟',
            ':vs:'                              => '🆚', ':vibration_mode:' => '📳', ':mobile_phone_off:' => '📴', ':chart:' => '💹',
            ':currency_exchange:'               => '💱', ':aries:' => '♈️', ':taurus:' => '♉️', ':gemini:' => '♊️',
            ':cancer:'                          => '♋️', ':leo:' => '♌️', ':virgo:' => '♍️', ':libra:' => '♎️',
            ':scorpius:'                        => '♏️', ':sagittarius:' => '♐️', ':capricorn:' => '♑️', ':aquarius:' => '♒️',
            ':pisces:'                          => '♓️', ':ophiuchus:' => '⛎', ':six_pointed_star:' => '🔯', ':negative_squared_cross_mark:' => '❎',
            ':a:'                               => '🅰️', ':b:' => '🅱️', ':ab:' => '🆎', ':o2:' => '🅾️',
            ':diamond_shape_with_a_dot_inside:' => '💠', ':recycle:' => '♻️', ':end:' => '🔚', ':on:' => '🔛',
            ':soon:'                            => '🔜', ':clock1:' => '🕐', ':clock130:' => '🕜', ':clock10:' => '🕙',
            ':clock1030:'                       => '🕥', ':clock11:' => '🕚', ':clock1130:' => '🕦', ':clock12:' => '🕛',
            ':clock1230:'                       => '🕧', ':clock2:' => '🕑', ':clock230:' => '🕝', ':clock3:' => '🕒',
            ':clock330:'                        => '🕞', ':clock4:' => '🕓', ':clock430:' => '🕟', ':clock5:' => '🕔',
            ':clock530:'                        => '🕠', ':clock6:' => '🕕', ':clock630:' => '🕡', ':clock7:' => '🕖',
            ':clock730:'                        => '🕢', ':clock8:' => '🕗', ':clock830:' => '🕣', ':clock9:' => '🕘',
            ':clock930:'                        => '🕤', ':heavy_dollar_sign:' => '💲', ':copyright:' => '©️', ':registered:' => '®️',
            ':tm:'                              => '™️', ':x:' => '❌', ':heavy_exclamation_mark:' => '❗️', ':bangbang:' => '‼️',
            ':interrobang:'                     => '⁉️', ':o:' => '⭕️', ':heavy_multiplication_x:' => '✖️', ':heavy_plus_sign:' => '➕',
            ':heavy_minus_sign:'                => '➖', ':heavy_division_sign:' => '➗', ':white_flower:' => '💮', ':100:' => '💯',
            ':heavy_check_mark:'                => '✔️', ':ballot_box_with_check:' => '☑️', ':radio_button:' => '🔘', ':link:' => '🔗',
            ':curly_loop:'                      => '➰', ':wavy_dash:' => '〰️', ':part_alternation_mark:' => '〽️', ':trident:' => '🔱',
            ':white_check_mark:'                => '✅', ':black_square_button:' => '🔲', ':white_square_button:' => '🔳', ':black_circle:' => '⚫️',
            ':white_circle:'                    => '⚪️', ':red_circle:' => '🔴', ':large_blue_circle:' => '🔵', ':large_blue_diamond:' => '🔷',
            ':large_orange_diamond:'            => '🔶', ':small_blue_diamond:' => '🔹', ':small_orange_diamond:' => '🔸', ':small_red_triangle:' => '🔺',
            ':small_red_triangle_down:'         => '🔻', ':black_small_square:' => '▪️', ':black_medium_small_square:' => '◾', ':black_medium_square:' => '◼️',
            ':black_large_square:'              => '⬛', ':white_small_square:' => '▫️', ':white_medium_small_square:' => '◽', ':white_medium_square:' => '◻️',
            ':white_large_square:'              => '⬜',
        ];

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'text' => \str_replace(\array_keys($emojiMap), $emojiMap, $matches[0]),
            ],
        ];
    }

    /**
     * Handle marks
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineMark(array $excerpt) : ?array
    {
        if (!\str_starts_with($excerpt['text'], '==')
            || \preg_match('/^(==)([^=]*?)(==)/', $excerpt['text'], $matches) !== 1
        ) {
            return null;
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name' => 'mark',
                'text' => $matches[2],
            ],
        ];
    }

    /**
     * Handle marks
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineSpoiler(array $excerpt) : ?array
    {
        if (!\str_starts_with($excerpt['text'], '>!')
            || \preg_match('/^>!(.*?)!</us', $excerpt['text'], $matches) !== 1
        ) {
            return null;
        }

        // @todo Optimize away the child <span> element for spoilers (if reasonable)
        //      If possible don't forget to adjust scss
        //      https://github.com/Karaka-Management/phpOMS/issues/367
        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'       => 'span',
                'attributes' => [
                    'class' => 'spoiler',
                ],
                'elements' => [
                    [
                        'name'       => 'input',
                        'attributes' => [
                            'type' => 'checkbox',
                        ],
                    ],
                    [
                        'name' => 'span',
                        'text' => $matches[1],
                    ],
                ],
            ],
        ];
    }

    /**
     * Handle keystrokes
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineKeystrokes(array $excerpt) : ?array
    {
        if (!\str_starts_with($excerpt['text'], '[[')
            || \preg_match('/^(?<!\[)(?:\[\[([^\[\]]*|[\[\]])\]\])(?!\])/s', $excerpt['text'], $matches) !== 1
        ) {
            return null;
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name' => 'kbd',
                'text' => $matches[1],
            ],
        ];
    }

    /**
     * Handle embedding
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineEmbedding(array $excerpt) : ?array
    {
        $video = false;
        $audio = false;

        if (!($this->options['embedding'] ?? false)
            || (!\str_starts_with($excerpt['text'], '[video') && !\str_starts_with($excerpt['text'], '[audio'))
            || (!($video = (\preg_match('/\[video.*src="([^"]*)".*\]/', $excerpt['text'], $matches) === 1))
                && !($audio = (\preg_match('/\[audio.*src="([^"]*)".*\]/', $excerpt['text'], $matches) === 1)))
        ) {
            return null;
        }

        $url = $matches[1];
        if ($video) {
            $type = '';

            $needles = ['youtube', 'vimeo', 'dailymotion'];
            foreach ($needles as $needle) {
                if (\strpos($url, $needle) !== false) {
                    $type = $needle;
                }
            }

            switch ($type) {
                case 'youtube':
                    $element    = 'iframe';
                    $attributes = [
                        'src'             => \preg_replace('/.*\?v=([^\&\]]*).*/', 'https://www.youtube.com/embed/$1', $url),
                        'frameborder'     => '0',
                        'allow'           => 'autoplay',
                        'allowfullscreen' => '',
                        'sandbox'         => 'allow-same-origin allow-scripts allow-forms',
                    ];
                    break;
                case 'vimeo':
                    $element    = 'iframe';
                    $attributes = [
                        'src'             => \preg_replace('/(?:https?:\/\/(?:[\w]{3}\.|player\.)*vimeo\.com(?:[\/\w:]*(?:\/videos)?)?\/([0-9]+)[^\s]*)/', 'https://player.vimeo.com/video/$1', $url),
                        'frameborder'     => '0',
                        'allow'           => 'autoplay',
                        'allowfullscreen' => '',
                        'sandbox'         => 'allow-same-origin allow-scripts allow-forms',
                    ];
                    break;
                case 'dailymotion':
                    $element    = 'iframe';
                    $attributes = [
                        'src'             => $url,
                        'frameborder'     => '0',
                        'allow'           => 'autoplay',
                        'allowfullscreen' => '',
                        'sandbox'         => 'allow-same-origin allow-scripts allow-forms',
                    ];
                    break;
                default:
                    $element    = 'video';
                    $attributes = [
                        'src'      => UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $url)),
                        'controls' => '',
                    ];
            }

            return [
                'extent'  => \strlen($matches[0]),
                'element' => [
                    'name'       => $element,
                    'text'       => $matches[1],
                    'attributes' => $attributes,
                ],
            ];
        } elseif ($audio) {
            return [
                'extent'  => \strlen($matches[0]),
                'element' => [
                    'name'       => 'audio',
                    'text'       => $matches[1],
                    'attributes' => [
                        'src'      => UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $url)),
                        'controls' => '',
                    ],
                ],
            ];
        }

        return null;
    }

    /**
     * Handle map
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineMap(array $excerpt) : ?array
    {
        if (!($this->options['map'] ?? false)
            || !\str_starts_with($excerpt['text'], '[map')
            || (\preg_match('/\[map(?:\s+(?:name="([^"]+)"|country="([^"]+)"|city="([^"]+)"|zip="([^"]+)"|address="([^"]+)"|lat="([^"]+)"|lon="([^"]+)")){0,7}\]/', $excerpt['text'], $matches) !== 1)
        ) {
            return null;
        }

        $name    = $matches[1];
        $country = $matches[2];
        $city    = $matches[3];
        $zip     = $matches[4];
        $address = $matches[5];

        $lat = $matches[6];
        $lon = $matches[7];

        if ($lat === '' || $lon === '') {
            [$lat, $lon] = \phpOMS\Api\Geocoding\Nominatim::geocoding($country, $city, $address, $zip);
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'       => 'div',
                'text'       => '',
                'attributes' => [
                    'id'       => 'i' . \bin2hex(\random_bytes(4)),
                    'class'    => 'map',
                    'data-lat' => $lat,
                    'data-lon' => $lon,
                ],
            ],
        ];
    }

    /**
     * Handle address
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineAddress(array $excerpt) : ?array
    {
        if (!($this->options['address'] ?? false)
            || !\str_starts_with($excerpt['text'], '[addr')
            || (\preg_match('/\[addr(?:\s+(?:name="([^"]+)"|country="([^"]+)"|city="([^"]+)"|zip="([^"]+)"|address="([^"]+)")){0,5}\]/', $excerpt['text'], $matches) !== 1)
        ) {
            return null;
        }

        $name    = $matches[1];
        $country = $matches[2];
        $city    = $matches[3];
        $zip     = $matches[4];
        $address = $matches[5];

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name' => 'div',
                //'text' => '',
                'attributes' => [
                    'class' => 'addressWidget',
                ],
                'elements' => [
                    [
                        'name'       => 'span',
                        'text'       => $name,
                        'attributes' => ['class' => 'addressWidget-name'],
                    ],
                    [
                        'name'       => 'span',
                        'text'       => $address,
                        'attributes' => ['class' => 'addressWidget-address'],
                    ],
                    [
                        'name'       => 'span',
                        'text'       => $zip,
                        'attributes' => ['class' => 'addressWidget-zip'],
                    ],
                    [
                        'name'       => 'span',
                        'text'       => $city,
                        'attributes' => ['class' => 'addressWidget-city'],
                    ],
                    [
                        'name'       => 'span',
                        'text'       => $country,
                        'attributes' => ['class' => 'addressWidget-country'],
                    ],
                ],
            ],
        ];
    }

    /**
     * Handle contact
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineContact(array $excerpt) : ?array
    {
        if (!($this->options['contact'] ?? false)
            || !\str_starts_with($excerpt['text'], '[contact')
            || (\preg_match('/\[contact.*?([a-zA-Z]+)="(.*?)"\]/', $excerpt['text'], $matches) !== 1)
        ) {
            return null;
        }

        $src = '';
        switch ($matches[1]) {
            case 'email':
                $src = 'Resources/icons/company/email.svg';
                break;
            case 'phone':
                $src = 'Resources/icons/company/phone.svg';
                break;
            case 'twitter':
                $src = 'Resources/icons/company/twitter.svg';
                break;
            case 'instagram':
                $src = 'Resources/icons/company/instagram.svg';
                break;
            case 'discord':
                $src = 'Resources/icons/company/discord.svg';
                break;
            case 'slack':
                $src = 'Resources/icons/company/slack.svg';
                break;
            case 'teams':
                $src = 'Resources/icons/company/teams.svg';
                break;
            case 'facebook':
                $src = 'Resources/icons/company/facebook.svg';
                break;
            case 'youtube':
                $src = 'Resources/icons/company/youtube.svg';
                break;
            case 'paypal':
                $src = 'Resources/icons/company/paypal.svg';
                break;
            case 'linkedin':
                $src = 'Resources/icons/company/linkedin.svg';
                break;
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name' => 'a',
                //'text' => '',
                'attributes' => [
                    'class' => 'contactWidget',
                    'href'  => '',
                ],
                'elements' => [
                    [
                        'name'       => 'img',
                        'attributes' => [
                            'class' => 'contactWidget-icon',
                            'src'   => $src,
                        ],
                    ],
                    [
                        'name'       => 'span',
                        'text'       => $matches[2],
                        'attributes' => ['class' => 'contactWidget-contact'],
                    ],
                ],

            ],
        ];
    }

    /**
     * Handle progress
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineProgress(array $excerpt) : ?array
    {
        if (!($this->options['progress'] ?? false)
            || !\str_starts_with($excerpt['text'], '[progress')
            || (\preg_match('/\[progress(?:\s+(?:type="([^"]+)"|percent="([^"]+)"|value="([^"]+)")){0,3}\]/', $excerpt['text'], $matches) !== 1)
        ) {
            return null;
        }

        // $type = empty($matches[1]) ? 'meter' : $matches[1];
        $percent = empty($matches[2]) ? $matches[3] : $matches[2];
        $value   = empty($matches[3]) ? $matches[2] : $matches[3];

        if ($percent === ''
            || $value === ''
        ) {
            return null;
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'       => 'progress',
                'text'       => '',
                'attributes' => [
                    'value' => $value,
                    'max'   => '100',
                ],
            ],
        ];
    }

    /**
     * Handle super script
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineSuperscript(array $excerpt) : ?array
    {
        if (\preg_match('/(?:\^(?!\^)([^\^ ]*)\^(?!\^))/', $excerpt['text'], $matches) !== 1) {
            return null;
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'     => 'sup',
                'text'     => $matches[1],
                'function' => 'lineElements',
            ],
        ];
    }

    /**
     * Handle sub script
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineSubscript(array $excerpt) : ?array
    {
        if (\preg_match('/(?:~(?!~)([^~ ]*)~(?!~))/', $excerpt['text'], $matches) !== 1) {
            return null;
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'     => 'sub',
                'text'     => $matches[1],
                'function' => 'lineElements',
            ],
        ];
    }

    /**
     * Handle typographer
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineTypographer(array $excerpt) : ?array
    {
        if (\preg_match('/\+-|\(p\)|\(tm\)|\(r\)|\(c\)|\.{2,}|\!\.{3,}|\?\.{3,}/i', $excerpt['text'], $matches) !== 1) {
            return null;
        }

        $substitutions = [
            '/\(c\)/i'        => '&copy;',
            '/\(r\)/i'        => '&reg;',
            '/\(tm\)/i'       => '&trade;',
            '/\(p\)/i'        => '&para;',
            '/\+-/i'          => '&plusmn;',
            '/\.{4,}|\.{2}/i' => '...',
            '/\!\.{3,}/i'     => '!..',
            '/\?\.{3,}/i'     => '?..',
        ];

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'rawHtml' => \preg_replace(\array_keys($substitutions), \array_values($substitutions), $matches[0]),
            ],
        ];
    }

    /**
     * Handle math
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineMath(array $excerpt) : ?array
    {
        $matchSingleDollar = $this->options['math']['single_dollar'] ?? false;
        if ($matchSingleDollar) {
            // Match single dollar - experimental
            if (\preg_match('/^(?<!\\\\)((?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)|(?<!\\\\\()\\\\\((.*?)(?<!\\\\\()\\\\\)(?!\\\\\)))/s', $excerpt['text'], $matches)) {
                $mathMatch = $matches[0];
            }
        } elseif (\preg_match('/^(?<!\\\\\()\\\\\((.*?)(?<!\\\\\()\\\\\)(?!\\\\\))/s', $excerpt['text'], $matches)) {
            $mathMatch = $matches[0];
        }

        if (!isset($mathMatch)) {
            return null;
        }

        return [
            'extent'  => \strlen($mathMatch),
            'element' => [
                'text' => $mathMatch,
            ],
        ];
    }

    /**
     * Handle escape sequence
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineEscapeSequence(array $excerpt) : ?array
    {
        if (!isset($excerpt['text'][1])
            || !\in_array($excerpt['text'][1], $this->specialCharacters)
        ) {
            return null;
        }

        $state = $this->options['math'] ?? false;
        if (!$state
            || !\preg_match('/^(?<!\\\\)(?<!\\\\\()\\\\\((.{2,}?)(?<!\\\\\()\\\\\)(?!\\\\\))/s', $excerpt['text'])
        ) {
            return [
                'extent'  => 2,
                'element' => [
                    'rawHtml' => $excerpt['text'][1],
                ],
            ];
        }

        return null;
    }

    /**
     * Handle block footnote
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockFootnote(array $line, ?array $_ = null) : ?array
    {
        return ($this->options['footnotes'] ?? true)
            && \preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $line['text'], $matches) == 1
            ? [
                'label'  => $matches[1],
                'text'   => $matches[2],
                'hidden' => true,
            ]
            : null;
    }

    /**
     * Handle block definition list
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param null|array                                  $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockDefinitionList(array $line, ?array $block = null) : ?array
    {
        if (!($this->options['definition_lists'] ?? true)
            || $block === null
            || $block['type'] !== 'Paragraph'
        ) {
            return null;
        }

        $element = [
            'name'     => 'dl',
            'elements' => [],
        ];

        $terms = \explode("\n", $block['element']['handler']['argument']);

        foreach ($terms as $term) {
            $element['elements'][] = [
                'name'    => 'dt',
                'handler' => [
                    'function'    => 'lineElements',
                    'argument'    => $term,
                    'destination' => 'elements',
                ],
            ];
        }

        $block['element'] = $element;

        return $this->addDdElement($line, $block);
    }

    /**
     * Handle block code
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param null|array                                  $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockCode(array $line, ?array $block = null) : ?array
    {
        if (!($this->options['code']['blocks'] ?? true)
            || !($this->options['code'] ?? true)
            || ($block !== null && $block['type'] === 'Paragraph' && !isset($block['interrupted']))
            || $line['indent'] < 4
        ) {
            return null;
        }

        return [
            'element' => [
                'name'    => 'pre',
                'element' => [
                    'name' => 'code',
                    'text' => \substr($line['body'], 4),
                ],
            ],
        ];
    }

    /**
     * Handle block comment
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockComment(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['comments'] ?? true)
            || $this->markupEscaped || $this->safeMode
            || !\str_starts_with($line['text'], '<!--')
        ) {
            return null;
        }

        $block = [
            'element' => [
                'rawHtml'   => $line['body'],
                'autobreak' => true,
            ],
        ];

        if (\strpos($line['text'], '-->') !== false) {
            $block['closed'] = true;
        }

        return $block;
    }

    /**
     * Handle block comment
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockHeader(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['headings'] ?? true)) {
            return null;
        }

        $level = \strspn($line['text'], '#');
        if ($level > 6) {
            return null;
        }

        $text = \trim($line['text'], '#');
        if ($this->strictMode && isset($text[0]) && $text[0] !== ' ') {
            return null;
        }

        $text = \trim($text, ' ');

        $block = [
            'element' => [
                'name'    => 'h' . $level,
                'handler' => [
                    'function'    => 'lineElements',
                    'argument'    => $text,
                    'destination' => 'elements',
                ],
            ],
        ];

        if (\preg_match('/[ #]*{(' . $this->regexAttribute . '+)}[ ]*$/', $block['element']['handler']['argument'], $matches, \PREG_OFFSET_CAPTURE)) {
            $attributeString = $matches[1][0];

            $block['element']['attributes']          = $this->parseAttributeData($attributeString);
            $block['element']['handler']['argument'] = \substr($block['element']['handler']['argument'], 0, (int) $matches[0][1]);
        }

        // Get the text of the heading
        $text = $block['element']['handler']['argument'];
        /*
        if (isset($block['element']['handler']['argument'])) {
            $text = $block['element']['handler']['argument'];
        }
        */

        // Get the heading level. Levels are h1, h2, ..., h6
        $level = $block['element']['name'];

        $headersAllowed = $this->options['headings']['allowed'] ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
        if (!\in_array($level, $headersAllowed)) {
            return null;
        }

        // Checks if auto generated anchors is allowed
        $autoAnchors = $this->options['headings']['auto_anchors'] ?? true;

        $id = $block['element']['attributes']['id'] ?? ($autoAnchors ? $this->createAnchorID($text) : null);

        // Set attributes to head tags
        $block['element']['attributes']['id'] = $id;

        $tocHeaders = $this->options['toc']['headings'] ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
        // Check if level are defined as a heading
        if (\in_array($level, $tocHeaders)) {
            // Add/stores the heading element info to the ToC list
            $this->setContentsList([
                'text'  => $text,
                'id'    => $id,
                'level' => $level,
            ]);
        }

        return $block;
    }

    /**
     * Handle block list
     *
     * @param array{body:string, indent:int, text:string} $line    Line data
     * @param null|array                                  $current Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockList(array $line, ?array $current = null) : ?array
    {
        if (!($this->options['lists'] ?? true)) {
            return null;
        }

        [$name, $pattern] = $line['text'][0] <= '-' ? ['ul', '[*+-]'] : ['ol', '[0-9]{1,9}+[.\)]'];

        if (\preg_match('/^(' . $pattern . '([ ]++|$))(.*+)/', $line['text'], $matches) !== 1) {
            return null;
        }

        $contentIndent = \strlen($matches[2]);
        if ($contentIndent >= 5) {
            --$contentIndent;

            $matches[1] = \substr($matches[1], 0, -$contentIndent);
            $matches[3] = \str_repeat(' ', $contentIndent) . $matches[3];
        } elseif ($contentIndent === 0) {
            $matches[1] .= ' ';
        }

        $markerWithoutWhitespace = \strstr($matches[1], ' ', true);
        if ($markerWithoutWhitespace === false) {
            $markerWithoutWhitespace = $matches[1];
        }

        if ($name !== 'ul') {
            $markerWithoutWhitespace = \substr($markerWithoutWhitespace, -1);

            /*
            if ($markerWithoutWhitespace === false) {
                $markerWithoutWhitespace = $matches[1];
            }
            */
        }

        $block = [
            'indent'  => $line['indent'],
            'pattern' => $pattern,
            'data'    => [
                'type'       => $name,
                'marker'     => $matches[1],
                'markerType' => $markerWithoutWhitespace,
            ],
            'element' => [
                'name'     => $name,
                'elements' => [],
            ],
        ];

        $block['data']['markerTypeRegex'] = \preg_quote($block['data']['markerType'], '/');

        if ($name === 'ol') {
            $tmp = \strstr($matches[1], $block['data']['markerType'], true);
            if ($tmp === false) {
                $tmp = $matches[1];
            }

            $listStart = \ltrim($tmp, '0') ?: '0';

            if ($listStart !== '0') {
                if (isset($current)
                    && $current['type'] === 'Paragraph'
                    && !isset($current['interrupted'])
                ) {
                    return null;
                }

                $block['element']['attributes'] = ['start' => $listStart];
            }
        }

        $block['li'] = [
            'name'    => 'li',
            'handler' => [
                'function'    => 'li',
                'argument'    => empty($matches[3]) ? [] : [$matches[3]],
                'destination' => 'elements',
            ],
        ];

        $block['element']['elements'][] = &$block['li'];

        return $block;
    }

    /**
     * Handle block quote
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockQuote(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['qoutes'] ?? true)
            || \preg_match('/^>[ ]?+(.*+)/', $line['text'], $matches) !== 1
        ) {
            return null;
        }

        return [
            'element' => [
                'name'    => 'blockquote',
                'handler' => [
                    'function'    => 'linesElements',
                    'argument'    => (array) $matches[1],
                    'destination' => 'elements',
                ],
            ],
        ];
    }

    /**
     * Handle block rule
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockRule(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['thematic_breaks'] ?? true)) {
            return null;
        }

        $marker = $line['text'][0];
        if (\substr_count($line['text'], $marker) >= 3 && \rtrim($line['text'], " {$marker}") === '') {
            return [
                'element' => [
                    'name' => 'hr',
                ],
            ];
        }

        return null;
    }

    /**
     * Handle block header
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param null|array                                  $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockSetextHeader(array $line, ?array $block = null) : ?array
    {
        if (!($this->options['headings'] ?? true)
            || $block === null
            || $block['type'] !== 'Paragraph'
            || isset($block['interrupted'])
        ) {
            return null;
        }

        if ($line['indent'] < 4 && \rtrim(\rtrim($line['text'], ' '), $line['text'][0]) === '') {
            $block['element']['name'] = $line['text'][0] === '=' ? 'h1' : 'h2';
        }

        if (\preg_match('/[ ]*{(' . $this->regexAttribute . '+)}[ ]*$/', $block['element']['handler']['argument'], $matches, \PREG_OFFSET_CAPTURE)) {
            $attributeString = $matches[1][0];

            $block['element']['attributes']          = $this->parseAttributeData($attributeString);
            $block['element']['handler']['argument'] = \substr($block['element']['handler']['argument'], 0, (int) $matches[0][1]);
        }

        // Get the text of the heading
        $text = null;
        if (isset($block['element']['handler']['argument'])) {
            $text = $block['element']['handler']['argument'];
        }

        // Get the heading level. Levels are h1, h2, ..., h6
        $level = $block['element']['name'];

        $headersAllowed = $this->options['headings']['allowed'] ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
        if (!\in_array($level, $headersAllowed)) {
            return null;
        }

        // Checks if auto generated anchors is allowed
        $autoAnchors = $this->options['headings']['auto_anchors'] ?? true;

        $id = $block['element']['attributes']['id'] ?? ($autoAnchors ? $this->createAnchorID($text) : null);

        // Set attributes to head tags
        $block['element']['attributes']['id'] = $id;

        $headersAllowed = $this->options['headings']['allowed'] ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];

        // Check if level are defined as a heading
        if (\in_array($level, $headersAllowed)) {
            // Add/stores the heading element info to the ToC list
            $this->setContentsList([
                'text'  => $text,
                'id'    => $id,
                'level' => $level,
            ]);
        }

        return $block;
    }

    /**
     * Handle block markup
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockMarkup(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['markup'] ?? true)
            || $this->markupEscaped || $this->safeMode
            || \preg_match('/^<(\w[\w-]*)(?:[ ]*' . $this->regexHtmlAttribute . ')*[ ]*(\/)?>/', $line['text'], $matches) !== 1
        ) {
            return null;
        }

        $element = \strtolower($matches[1]);

        if (\in_array($element, $this->textLevelElements)) {
            return null;
        }

        $block = [
            'name'    => $matches[1],
            'depth'   => 0,
            'element' => [
                'rawHtml'   => $line['text'],
                'autobreak' => true,
            ],
        ];

        $length    = \strlen($matches[0]);
        $remainder = \substr($line['text'], $length);

        if (\trim($remainder) === '') {
            if (isset($matches[2]) || \in_array($matches[1], $this->voidElements)) {
                $block['closed'] = true;
                $block['void']   = true;
            }
        } else {
            if (isset($matches[2]) || \in_array($matches[1], $this->voidElements)) {
                return null;
            }

            if (\preg_match('/<\/' . $matches[1] . '>[ ]*$/i', $remainder)) {
                $block['closed'] = true;
            }
        }

        return $block;
    }

    /**
     * Handle block reference
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockReference(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['references'] ?? true)
            || \strpos($line['text'], ']') === false
            || \preg_match('/^\[(.+?)\]:[ ]*+<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $line['text'], $matches) !== 1

        ) {
            return null;
        }

        $id = \strtolower($matches[1]);

        $this->definitionData['Reference'][$id] = [
            'url'   => UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[2])),
            'title' => isset($matches[3]) ? $matches[3] : null,
        ];

        return [
            'element' => [],
        ];
    }

    /**
     * Handle block table
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param null|array                                  $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockTable(array $line, ?array $block = null) : ?array
    {
        if (!($this->options['tables'] ?? true)
            || $block === null || $block['type'] !== 'Paragraph' || isset($block['interrupted'])
            || (\strpos($block['element']['handler']['argument'], '|') === false
                && \strpos($line['text'], '|') === false
                && \strpos($line['text'], ':') === false
                || \strpos($block['element']['handler']['argument'], "\n") !== false)
            || \rtrim($line['text'], ' -:|') !== ''
        ) {
            return null;
        }

        $alignments = [];

        $divider = $line['text'];
        $divider = \trim($divider);
        $divider = \trim($divider, '|');

        $dividerCells = \explode('|', $divider);

        foreach ($dividerCells as $dividerCell) {
            $dividerCell = \trim($dividerCell);

            if ($dividerCell === '') {
                return null;
            }

            $alignment = null;

            if ($dividerCell[0] === ':') {
                $alignment = 'left';
            }

            if (\substr($dividerCell, - 1) === ':') {
                $alignment = $alignment === 'left' ? 'center' : 'right';
            }

            $alignments [] = $alignment;
        }

        $headerElements = [];

        $header = $block['element']['handler']['argument'];
        $header = \trim($header);
        $header = \trim($header, '|');

        $headerCells = \explode('|', $header);

        if (\count($headerCells) !== \count($alignments)) {
            return null;
        }

        foreach ($headerCells as $index => $headerCell) {
            $headerCell = \trim($headerCell);

            $headerElement = [
                'name'    => 'th',
                'handler' => [
                    'function'    => 'lineElements',
                    'argument'    => $headerCell,
                    'destination' => 'elements',
                ],
            ];

            if (isset($alignments[$index])) {
                $alignment = $alignments[$index];

                $headerElement['attributes'] = [
                    'style' => "text-align: {$alignment};",
                ];
            }

            $headerElements[] = $headerElement;
        }

        $block = [
            'alignments' => $alignments,
            'identified' => true,
            'element'    => [
                'name'     => 'table',
                'elements' => [],
            ],
        ];

        $block['element']['elements'][] = [
            'name' => 'thead',
        ];

        $block['element']['elements'][] = [
            'name'     => 'tbody',
            'elements' => [],
        ];

        $block['element']['elements'][0]['elements'][] = [
            'name'     => 'tr',
            'elements' => $headerElements,
        ];

        return $block;
    }

    /**
     * Handle block abbreviation
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockAbbreviation(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['abbreviations'] ?? true)) {
            return null;
        }

        $allowCustomAbbr = $this->options['abbreviations']['allow_custom_abbr'] ?? true;

        if (isset($this->options['abbreviations']['predefine'])) {
            foreach ($this->options['abbreviations']['predefine'] as $abbreviations => $description) {
                $this->definitionData['Abbreviation'][$abbreviations] = $description;
            }
        }

        if (!$allowCustomAbbr
            || \preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $line['text'], $matches) !== 1
        ) {
            return null;
        }

        $this->definitionData['Abbreviation'][$matches[1]] = $matches[2];

        return [
            'hidden' => true,
        ];
    }

    /**
     * Handle block math
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockMath(array $line, ?array $_ = null) : ?array
    {
        $block = [
            'element' => [
                'text' => '',
            ],
        ];

        if (\preg_match('/^(?<!\\\\)(\\\\\[)(?!.)$/', $line['text'])) {
            $block['end'] = '\]';

            return $block;
        }

        if (\preg_match('/^(?<!\\\\)(\$\$)(?!.)$/', $line['text'])) {
            $block['end'] = '$$';

            return $block;
        }

        return null;
    }

    /**
     * Continue block math
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockMathContinue(array $line, array $block) : ?array
    {
        if (isset($block['complete'])) {
            return null;
        }

        if (isset($block['interrupted'])) {
            $block['element']['text'] .= \str_repeat("\n", $block['interrupted']);

            unset($block['interrupted']);
        }

        if (\preg_match('/^(?<!\\\\)(\\\\\])$/', $line['text']) && $block['end'] === '\]') {
            $block['complete']        = true;
            $block['math']            = true;
            $block['element']['text'] = '\\[' . $block['element']['text'] . '\\]';

            return $block;
        }

        if (\preg_match('/^(?<!\\\\)(\$\$)$/', $line['text']) && $block['end'] === '$$') {
            $block['complete']        = true;
            $block['math']            = true;
            $block['element']['text'] = '$$' . $block['element']['text'] . '$$';

            return $block;
        }

        $block['element']['text'] .= "\n" . $line['body'];

        return $block;
    }

    /**
     * Complete block math
     *
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function blockMathComplete(array $block) : array
    {
        return $block;
    }

    /**
     * Continue block math
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockFencedCode(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['code']['blocks'] ?? true)
            || !($this->options['code'] ?? true)
        ) {
            return null;
        }

        $marker       = $line['text'][0];
        $openerLength = \strspn($line['text'], $marker);

        if ($openerLength < 3) {
            return null;
        }

        $language = \trim(\preg_replace('/^`{3}([^\s]+)(.+)?/s', '$1', $line['text']) ?? '');

        if (!($this->options['diagrams'] ?? true)
            || !\in_array($language, ['mermaid', 'chartjs', 'tuichart'])
        ) {
            // Is code block
            $element = [
                'name' => 'code',
                'text' => '',
            ];

            if ($language !== '```' && !empty($language)) {
                $element['attributes'] = ['class' => "language-{$language}"];
            }

            return [
                'char'         => $marker,
                'openerLength' => $openerLength,
                'element'      => [
                    'name'    => 'pre',
                    'element' => $element,
                ],
            ];
        } elseif (\strtolower($language) === 'chartjs') {
            // Chart.js https://www.chartjs.org/
            $element = [
                'text' => '',
            ];

            return [
                'char'         => $marker,
                'openerLength' => $openerLength,
                'element'      => [
                    'element'    => $element,
                    'name'       => 'canvas',
                    'attributes' => [
                        'class' => 'chartjs',
                    ],
                ],
            ];
        } elseif (\in_array(\strtolower($language), ['mermaid', 'tuichart'])) {
            // Mermaid.js https://mermaidjs.github.io
            // TUI.chart https://github.com/nhn/tui.chart
            $element = [
                'text' => '',
            ];

            return [
                'char'         => $marker,
                'openerLength' => $openerLength,
                'element'      => [
                    'element'    => $element,
                    'name'       => 'div',
                    'attributes' => [
                        'class' => \strtolower($language),
                    ],
                ],
            ];
        }

        return null;
    }

    /**
     * Continue block spoiler
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockSpoiler(array $line, ?array $_ = null) : ?array
    {
        if (!($this->options['code']['blocks'] ?? true)
            || !($this->options['code'] ?? true)
        ) {
            return null;
        }

        $marker       = $line['text'][0];
        $openerLength = \strspn($line['text'], $marker);

        if ($openerLength < 3) {
            return null;
        }

        $summary = \trim(\preg_replace('/^\?{3}(.+)?/s', '$1', $line['text']) ?? '');

        $infostring = \trim(\substr($line['text'], $openerLength), "\t ");
        if (\strpos($infostring, '?') !== false) {
            return null;
        }

        // @performance Optimize away the child <span> element for spoilers (if reasonable)
        //      https://github.com/Karaka-Management/phpOMS/issues/367
        return [
            'char'         => $marker,
            'openerLength' => $openerLength,
            'element'      => [
                'name'    => 'details',
                'element' => [
                    'text'     => '',
                    'elements' => [
                        [
                            'name' => 'summary',
                            'text' => $summary,
                        ],
                        [
                            'name' => 'span',
                            'text' => '',
                        ],
                    ],
                ],
            ],
        ];
    }

    /**
     * Complete block table
     *
     * @param array $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockTableComplete(array $block) : ?array
    {
        if (!($this->options['tables']['tablespan'] ?? true)) {
            return $block;
        }

        $headerElements      = &$block['element']['elements'][0]['elements'][0]['elements'];
        $headerElementsCount = \count($headerElements);

        for ($index = $headerElementsCount - 1; $index >= 0; --$index) {
            $colspan       = 1;
            $headerElement = &$headerElements[$index];

            while ($index && $headerElements[$index - 1]['handler']['argument'] === '>') {
                ++$colspan;
                $previousHeaderElement           = &$headerElements[--$index];
                $previousHeaderElement['merged'] = true;

                if (isset($previousHeaderElement['attributes'])) {
                    $headerElement['attributes'] = $previousHeaderElement['attributes'];
                }
            }

            if ($colspan > 1) {
                if (!isset($headerElement['attributes'])) {
                    $headerElement['attributes'] = [];
                }

                $headerElement['attributes']['colspan'] = $colspan;
            }
        }

        for ($index = 0; $index < $headerElementsCount; ++$index) {
            if (isset($headerElements[$index]['merged'])) {
                unset($headerElements[$index]);
            }
        }

        $headerElements = \array_values($headerElements);

        $rows = &$block['element']['elements'][1]['elements'];

        foreach ($rows as $rowNo => &$row) {
            $elements = &$row['elements'];

            for ($index = \count($elements) - 1; $index >= 0; --$index) {
                $colspan = 1;
                $element = &$elements[$index];

                while ($index && $elements[$index - 1]['handler']['argument'] === '>') {
                    ++$colspan;
                    $PreviousElement           = &$elements[--$index];
                    $PreviousElement['merged'] = true;
                    if (isset($PreviousElement['attributes'])) {
                        $element['attributes'] = $PreviousElement['attributes'];
                    }
                }

                if ($colspan > 1) {
                    if (!isset($element['attributes'])) {
                        $element['attributes'] = [];
                    }

                    $element['attributes']['colspan'] = $colspan;
                }
            }
        }

        $rowCount = \count($rows);

        foreach ($rows as $rowNo => &$row) {
            $elements = &$row['elements'];

            foreach ($elements as $index => &$element) {
                $rowspan = 1;

                if (isset($element['merged'])) {
                    continue;
                }

                while ($rowNo + $rowspan < $rowCount
                    && $index < \count($rows[$rowNo + $rowspan]['elements'])
                    && $rows[$rowNo + $rowspan]['elements'][$index]['handler']['argument'] === '^'
                    && ($element['attributes']['colspan'] ?? null) === ($rows[$rowNo + $rowspan]['elements'][$index]['attributes']['colspan'] ?? null)
                ) {
                    $rows[$rowNo + $rowspan]['elements'][$index]['merged'] = true;
                    ++$rowspan;
                }

                if ($rowspan > 1) {
                    if (!isset($element['attributes'])) {
                        $element['attributes'] = [];
                    }

                    $element['attributes']['rowspan'] = $rowspan;
                }
            }
        }

        foreach ($rows as $rowNo => &$row) {
            $elements = &$row['elements'];

            for ($index = \count($elements) - 1; $index >= 0; --$index) {
                if (isset($elements[$index]['merged'])) {
                    unset($elements[$index]);
                }
            }

            $row['elements'] = \array_values($elements);
        }

        return $block;
    }

    /**
     * Handle block checkbox
     *
     * @param array{body:string, indent:int, text:string} $line Line data
     * @param null|array                                  $_    Current block (unused parameter)
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockCheckbox(array $line, ?array $_ = null) : ?array
    {
        $text      = \trim($line['text']);
        $beginLine = \substr($text, 0, 4);

        if ($beginLine === '[ ] ') {
            return [
                'handler' => 'unchecked',
                'text'    => \substr(\trim($text), 4),
            ];
        } elseif ($beginLine === '[x] ') {
            return [
                'handler' => 'checked',
                'text'    => \substr(\trim($text), 4),
            ];
        }

        return null;
    }

    /**
     * Continue checkbox.
     *
     * This function doesn't do anything!
     * However required as per the parsing workflow since it is automatically called.
     *
     * @param array{body:string, indent:int, text:string} $_  Line data
     * @param array                                       $__ Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockCheckboxContinue(array $_, array $__) : ?array
    {
        return null;
    }

    /**
     * Complete block checkbox
     *
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function blockCheckboxComplete(array $block) : array
    {
        $text = $block['text'];
        if ($this->markupEscaped || $this->safeMode) {
            $text = \htmlspecialchars($text, \ENT_QUOTES, 'UTF-8');
        }

        $html = $block['handler'] === 'unchecked'
            ? '<input type="checkbox" disabled /> ' . $this->formatOnce($text)
            : '<input type="checkbox" checked disabled /> ' . $this->formatOnce($text);

        $block['element'] = [
            'rawHtml'                => $html,
            'allowRawHtmlInSafeMode' => true,
        ];

        return $block;
    }

    /**
     * Formats text without double escaping
     *
     * @param string $text Text to format
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function formatOnce(string $text) : string
    {
        // backup settings
        $markupEscaped = $this->markupEscaped;
        $safeMode      = $this->safeMode;

        // disable rules to prevent double escaping.
        $this->markupEscaped = false;
        $this->safeMode      = false;

        // format line
        $text = $this->elements($this->lineElements($text));

        // reset old values
        $this->markupEscaped = $markupEscaped;
        $this->safeMode      = $safeMode;

        return $text;
    }

    /**
     * Parse attribute data
     *
     * @param string $attribute Attribute string
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function parseAttributeData(string $attribute) : array
    {
        if (!($this->options['special_attributes'] ?? true)) {
            return [];
        }

        $data       = [];
        $attributes = \preg_split('/[ ]+/', $attribute, - 1, \PREG_SPLIT_NO_EMPTY);
        $classes    = [];

        if ($attributes === false) {
            return [];
        }

        foreach ($attributes as $attribute) {
            if ($attribute[0] === '#') {
                $data['id'] = \substr($attribute, 1);
            } else { // "."
                $classes[] = \substr($attribute, 1);
            }
        }

        if (!empty($classes)) {
            $data['class'] = \implode(' ', $classes);
        }

        return $data;
    }

    /**
     * Encodes the ToC tag to a hashed tag and replace.
     *
     * This is used to avoid parsing user defined ToC tag which includes "_" in
     * their tag such as "[[_toc_]]".
     *
     * @param string $text Tag text to encode
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function encodeToCTagToHash(string $text) : string
    {
        $salt      = \bin2hex(\random_bytes(4));
        $tagOrigin = $this->options['toc']['set_toc_tag'] ?? '[toc]';

        if (\strpos($text, $tagOrigin) === false) {
            return $text;
        }

        $tagHashed = \hash('sha256', $salt . $tagOrigin);

        return \str_replace($tagOrigin, $tagHashed, $text);
    }

    /**
     * Decodes the hashed ToC tag to an original tag and replaces.
     *
     * This is used to avoid parsing user defined ToC tag which includes "_" in
     * their tag such as "[[_toc_]]".
     *
     * @param string $text Tag text to encode
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function decodeToCTagFromHash(string $text) : string
    {
        $salt      = \bin2hex(\random_bytes(4));
        $tagOrigin = $this->options['toc']['set_toc_tag'] ?? '[toc]';
        $tagHashed = \hash('sha256', $salt . $tagOrigin);

        if (\strpos($text, $tagHashed) === false) {
            return $text;
        }

        return \str_replace($tagHashed, $tagOrigin, $text);
    }

    /**
     * Generates an anchor text that are link-able even if the heading is not in ASCII.
     *
     * @param string $str Header text
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function createAnchorID(string $str) : string
    {
        if ($this->options['toc']['urlencode'] ?? false) {
            $str = $this->incrementAnchorId($str);

            return \urlencode($str);
        }

        $charMap = [
            // Latin
            'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'AA', 'Æ' => 'AE', 'Ç' => 'C',
            'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
            'Ð' => 'D', 'Ñ' => 'N', 'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O', 'Ő' => 'O',
            'Ø' => 'OE', 'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U', 'Ű' => 'U', 'Ý' => 'Y', 'Þ' => 'TH',
            'ß' => 'ss',
            'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'aa', 'æ' => 'ae', 'ç' => 'c',
            'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
            'ð' => 'd', 'ñ' => 'n', 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ő' => 'o',
            'ø' => 'oe', 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ű' => 'u', 'ý' => 'y', 'þ' => 'th',
            'ÿ' => 'y',

            // Latin symbols
            '©' => '(c)', '®' => '(r)', '™' => '(tm)',

            // Greek
            'Α' => 'A', 'Β' => 'B', 'Γ' => 'G', 'Δ' => 'D', 'Ε' => 'E', 'Ζ' => 'Z', 'Η' => 'H', 'Θ' => '8',
            'Ι' => 'I', 'Κ' => 'K', 'Λ' => 'L', 'Μ' => 'M', 'Ν' => 'N', 'Ξ' => '3', 'Ο' => 'O', 'Π' => 'P',
            'Ρ' => 'R', 'Σ' => 'S', 'Τ' => 'T', 'Υ' => 'Y', 'Φ' => 'F', 'Χ' => 'X', 'Ψ' => 'PS', 'Ω' => 'W',
            'Ά' => 'A', 'Έ' => 'E', 'Ί' => 'I', 'Ό' => 'O', 'Ύ' => 'Y', 'Ή' => 'H', 'Ώ' => 'W', 'Ϊ' => 'I',
            'Ϋ' => 'Y',
            'α' => 'a', 'β' => 'b', 'γ' => 'g', 'δ' => 'd', 'ε' => 'e', 'ζ' => 'z', 'η' => 'h', 'θ' => '8',
            'ι' => 'i', 'κ' => 'k', 'λ' => 'l', 'μ' => 'm', 'ν' => 'n', 'ξ' => '3', 'ο' => 'o', 'π' => 'p',
            'ρ' => 'r', 'σ' => 's', 'τ' => 't', 'υ' => 'y', 'φ' => 'f', 'χ' => 'x', 'ψ' => 'ps', 'ω' => 'w',
            'ά' => 'a', 'έ' => 'e', 'ί' => 'i', 'ό' => 'o', 'ύ' => 'y', 'ή' => 'h', 'ώ' => 'w', 'ς' => 's',
            'ϊ' => 'i', 'ΰ' => 'y', 'ϋ' => 'y', 'ΐ' => 'i',

            // Turkish
            'Ş' => 'S', 'İ' => 'I', 'Ğ' => 'G',
            'ş' => 's', 'ı' => 'i', 'ğ' => 'g',

            // Russian
            'А' => 'A', 'Б' => 'B', 'В' => 'V', 'Г' => 'G', 'Д' => 'D', 'Е' => 'E', 'Ё' => 'Yo', 'Ж' => 'Zh',
            'З' => 'Z', 'И' => 'I', 'Й' => 'J', 'К' => 'K', 'Л' => 'L', 'М' => 'M', 'Н' => 'N', 'О' => 'O',
            'П' => 'P', 'Р' => 'R', 'С' => 'S', 'Т' => 'T', 'У' => 'U', 'Ф' => 'F', 'Х' => 'H', 'Ц' => 'C',
            'Ч' => 'Ch', 'Ш' => 'Sh', 'Щ' => 'Sh', 'Ъ' => '', 'Ы' => 'Y', 'Ь' => '', 'Э' => 'E', 'Ю' => 'Yu',
            'Я' => 'Ya',
            'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', 'е' => 'e', 'ё' => 'yo', 'ж' => 'zh',
            'з' => 'z', 'и' => 'i', 'й' => 'j', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', 'о' => 'o',
            'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', 'у' => 'u', 'ф' => 'f', 'х' => 'h', 'ц' => 'c',
            'ч' => 'ch', 'ш' => 'sh', 'щ' => 'sh', 'ъ' => '', 'ы' => 'y', 'ь' => '', 'э' => 'e', 'ю' => 'yu',
            'я' => 'ya',

            // Ukrainian
            'Є' => 'Ye', 'І' => 'I', 'Ї' => 'Yi', 'Ґ' => 'G',
            'є' => 'ye', 'і' => 'i', 'ї' => 'yi', 'ґ' => 'g',

            // Czech
            'Č' => 'C', 'Ď' => 'D', 'Ě' => 'E', 'Ň' => 'N', 'Ř' => 'R', 'Š' => 'S', 'Ť' => 'T', 'Ů' => 'U',
            'Ž' => 'Z',
            'č' => 'c', 'ď' => 'd', 'ě' => 'e', 'ň' => 'n', 'ř' => 'r', 'š' => 's', 'ť' => 't', 'ů' => 'u',
            'ž' => 'z',

            // Polish
            'Ą' => 'A', 'Ć' => 'C', 'Ę' => 'e', 'Ł' => 'L', 'Ń' => 'N', 'Ś' => 'S', 'Ź' => 'Z',
            'Ż' => 'Z',
            'ą' => 'a', 'ć' => 'c', 'ę' => 'e', 'ł' => 'l', 'ń' => 'n', 'ś' => 's', 'ź' => 'z',
            'ż' => 'z',

            // Latvian
            'Ā' => 'A', 'Ē' => 'E', 'Ģ' => 'G', 'Ī' => 'i', 'Ķ' => 'k', 'Ļ' => 'L', 'Ņ' => 'N', 'Ū' => 'u',
            'ā' => 'a', 'ē' => 'e', 'ģ' => 'g', 'ī' => 'i', 'ķ' => 'k', 'ļ' => 'l', 'ņ' => 'n', 'ū' => 'u',
        ];

        // Transliterate characters to ASCII
        if ($this->options['toc']['transliterate'] ?? false) {
            $str = \str_replace(\array_keys($charMap), $charMap, $str);
        }

        // Replace non-alphanumeric characters with our delimiter
        $optionDelimiter = $this->options['toc']['delimiter'] ?? '-';
        $str             = \preg_replace('/[^\p{L}\p{Nd}]+/u', $optionDelimiter, $str) ?? '';

        // Remove duplicate delimiters
        $str = \preg_replace('/(' . \preg_quote($optionDelimiter, '/') . '){2,}/', '$1', $str) ?? '';

        // Truncate slug to max. characters
        $optionLimit = $this->options['toc']['limit'] ?? \mb_strlen($str, 'UTF-8');
        $str         = \mb_substr($str, 0, $optionLimit, 'UTF-8');

        // Remove delimiter from ends
        $str = \trim($str, $optionDelimiter);

        $urlLowercase = $this->options['toc']['lowercase'] ?? true;
        $str          = $urlLowercase ? \mb_strtolower($str, 'UTF-8') : $str;

        return $this->incrementAnchorId($str);
    }

    /**
     * Set/stores the heading block to ToC list in a string and array format.
     *
     * @param array $content ToC content
     *
     * @return void
     *
     * @since 1.0.0
     */
    protected function setContentsList(array $content) : void
    {
        $this->contentsListArray[] = $content;

        $text  = \trim(\strip_tags($this->elements($this->lineElements($content['text']))));
        $id    = $content['id'];
        $level = (int) \trim($content['level'], 'h');

        if ($this->firstHeadLevel === 0) {
            $this->firstHeadLevel = $level;
        }

        // Stores in markdown list format as below:
        // - [Header1](#Header1)
        //   - [Header2-1](#Header2-1)
        //     - [Header3](#Header3)
        //   - [Header2-2](#Header2-2)
        // ...
        $this->contentsListString .= \str_repeat(
            '  ',
            $this->firstHeadLevel - 1 > $level
                ? 1
                : $level - ($this->firstHeadLevel - 1)
        ) . ' - [' . $text . '](#' . $id . ")\n";
    }

    /**
     * Collect and count anchors in use to prevent duplicated ids.
     *
     * Also init optional blacklist of ids.
     *
     * @param string $str Header anchor
     *
     * @return string Incremental, numeric suffix
     *
     * @since 1.0.0
     */
    protected function incrementAnchorId(string $str) : string
    {
        // add blacklist to list of used anchors
        if (!$this->isBlacklistInitialized) {
            $this->initBlacklist();
        }

        do {
            $this->anchorDuplicates[$str] = isset($this->anchorDuplicates[$str]) ? ++$this->anchorDuplicates[$str] : 0;

            $newStr = $str;

            if (($count = $this->anchorDuplicates[$str]) === 0) {
                return $newStr;
            }

            $newStr .= '-' . $count;
        } while (isset($this->anchorDuplicates[$newStr]));

        $this->anchorDuplicates[$newStr] = 0;

        return $newStr;
    }

    /**
     * Add blacklisted ids to anchor list.
     *
     * @return void
     *
     * @since 1.0.0
     */
    protected function initBlacklist() : void
    {
        if ($this->isBlacklistInitialized) {
            return;
        }

        if (!empty($this->options['headings']['blacklist']) && \is_array($this->options['headings']['blacklist'])) {
            foreach ($this->options['headings']['blacklist'] as $v) {
                $this->anchorDuplicates[(string) $v] = 0;
            }
        }

        $this->isBlacklistInitialized = true;
    }

    /**
     * Parse inline elements
     *
     * @param string $text         Text to parse
     * @param array  $nonNestables Inline elements that are not allowed to be nested
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function lineElements(string $text, array $nonNestables = []) : array
    {
        $elements = [];

        if (!empty($nonNestables)) {
            $nonNestables = \array_combine($nonNestables, $nonNestables);
        }

        // $exc is based on the first occurrence of a marker
        while (($exc = \strpbrk($text, $this->inlineMarkerList)) !== false) {
            $marker         = $exc[0];
            $markerPosition = \strlen($text) - \strlen($exc);

            // Get the first char before the marker
            $beforeMarkerPosition = $markerPosition - 1;
            $charBeforeMarker     = $beforeMarkerPosition >= 0 ? $text[$markerPosition - 1] : '';

            $excerpt = ['text' => $exc, 'context' => $text, 'before' => $charBeforeMarker];

            foreach ($this->inlineTypes[$marker] as $inlineType) {
                // check to see if the current inline type is nestable in the current context
                if (isset($nonNestables[$inlineType])) {
                    continue;
                }

                $inline = $this->{"inline{$inlineType}"}($excerpt);
                if ($inline === null) {
                    continue;
                }

                // makes sure that the inline belongs to "our" marker
                if (isset($inline['position']) && $inline['position'] > $markerPosition) {
                    continue;
                }

                // sets a default inline position
                if (!isset($inline['position'])) {
                    $inline['position'] = $markerPosition;
                }

                // cause the new element to 'inherit' our non nestables
                $inline['element']['nonNestables'] = isset($inline['element']['nonNestables'])
                    ? \array_merge($inline['element']['nonNestables'], $nonNestables)
                    : $nonNestables;

                // the text that comes before the inline
                $unmarkedText = \substr($text, 0, $inline['position']);

                // compile the unmarked text
                $inlineText = $this->inlineText($unmarkedText);
                $elements[] = $inlineText['element'];

                // compile the inline
                $elements[] = $this->extractElement($inline);

                // remove the examined text
                $text = \substr($text, $inline['position'] + $inline['extent']);

                continue 2;
            }

            // the marker does not belong to an inline
            $unmarkedText = \substr($text, 0, $markerPosition + 1);

            $inlineText = $this->inlineText($unmarkedText);
            $elements[] = $inlineText['element'];

            $text = \substr($text, $markerPosition + 1);
        }

        $inlineText = $this->inlineText($text);
        $elements[] = $inlineText['element'];

        foreach ($elements as &$element) {
            if (!isset($element['autobreak'])) {
                $element['autobreak'] = false;
            }
        }

        return $elements;
    }

    /**
     * Continue block footnote
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockFootnoteContinue(array $line, array $block) : ?array
    {
        if ($line['text'][0] === '['
            && \preg_match('/^\[\^(.+?)\]:/', $line['text'])
        ) {
            return null;
        }

        if (isset($block['interrupted'])) {
            if ($line['indent'] >= 4) {
                $block['text'] .= "\n\n" . $line['text'];

                return $block;
            }
        } else {
            $block['text'] .= "\n" . $line['text'];

            return $block;
        }

        return null;
    }

    /**
     * Complete block footnote
     *
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function blockFootnoteComplete(array $block) : array
    {
        $this->definitionData['Footnote'][$block['label']] = [
            'text'   => $block['text'],
            'count'  => null,
            'number' => null,
        ];

        return $block;
    }

    /**
     * Continue block footnote
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockDefinitionListContinue(array $line, array $block) : ?array
    {
        if ($line['text'][0] === ':') {
            return $this->addDdElement($line, $block);
        }

        if (isset($block['interrupted']) && $line['indent'] === 0) {
            return null;
        }

        if (isset($block['interrupted'])) {
            $block['dd']['handler']['function'] = 'textElements';
            $block['dd']['handler']['argument'] .= "\n\n";

            $block['dd']['handler']['destination'] = 'elements';

            unset($block['interrupted']);
        }

        $text = \substr($line['body'], \min($line['indent'], 4));

        $block['dd']['handler']['argument'] .= "\n" . $text;

        return $block;
    }

    /**
     * Continue block markup
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockMarkupContinue(array $line, array $block) : ?array
    {
        if (isset($block['closed'])) {
            return null;
        }

        if (\preg_match('/^<' . $block['name'] . '(?:[ ]*' . $this->regexHtmlAttribute . ')*[ ]*>/i', $line['text'])) {
            // open
            ++$block['depth'];
        }

        if (\preg_match('/(.*?)<\/' . $block['name'] . '>[ ]*$/i', $line['text'], $matches)) {
            // close
            if ($block['depth'] > 0) {
                --$block['depth'];
            } else {
                $block['closed'] = true;
            }
        }

        if (isset($block['interrupted'])) {
            $block['element']['rawHtml'] .= "\n";
            unset($block['interrupted']);
        }

        $block['element']['rawHtml'] .= "\n".$line['body'];

        return $block;
    }

    /**
     * Complete block markup
     *
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function blockMarkupComplete(array $block) : array
    {
        if (!isset($block['void'])) {
            $block['element']['rawHtml'] = $this->processTag($block['element']['rawHtml']);
        }

        return $block;
    }

    /**
     * Handle footnote marker
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array{extent:int, element:array}
     *
     * @since 1.0.0
     */
    protected function inlineFootnoteMarker(array $excerpt) : ?array
    {
        if (\preg_match('/^\[\^(.+?)\]/', $excerpt['text'], $matches) !== 1) {
            return null;
        }

        $name = $matches[1];

        if (!isset($this->definitionData['Footnote'][$name])) {
            return null;
        }

        ++$this->definitionData['Footnote'][$name]['count'];

        if (!isset($this->definitionData['Footnote'][$name]['number'])) {
            $this->definitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; // » &
        }

        return [
            'extent'  => \strlen($matches[0]),
            'element' => [
                'name'       => 'sup',
                'attributes' => ['id' => 'fnref' . $this->definitionData['Footnote'][$name]['count'] . ':' . $name],
                'element'    => [
                    'name'       => 'a',
                    'attributes' => ['href' => '#fn:' . $name, 'class' => 'footnote-ref'],
                    'text'       => $this->definitionData['Footnote'][$name]['number'],
                ],
            ],
        ];
    }

    /**
     * Insert/replace text with abbreviation
     *
     * @param array $element Element to insert abbreviation into
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function insertAbbreviation(array $element) : array
    {
        if (!isset($element['text'])) {
            return $element;
        }

        $element['elements'] = self::pregReplaceElements(
            '/\b' . \preg_quote($this->currentAbbreviation, '/') . '\b/',
            [
                [
                    'name'       => 'abbr',
                    'attributes' => [
                        'title' => $this->currentMeaning,
                    ],
                    'text' => $this->currentAbbreviation,
                ],
            ],
            $element['text']
        );

        unset($element['text']);

        return $element;
    }

    /**
     * Inline elements in text
     *
     * @param string $text Text to search for inlinable elements
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function inlineText(string $text) : array
    {
        $inline = [
            'extent'  => \strlen($text),
            'element' => [],
        ];

        $inline['element']['elements'] = self::pregReplaceElements(
            $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/',
            [
                ['name' => 'br'],
                ['text' => "\n"],
            ],
            $text
        );

        // Handle abbreviations
        if (!isset($this->definitionData['Abbreviation'])) {
            return $inline;
        }

        foreach ($this->definitionData['Abbreviation'] as $abbreviation => $meaning) {
            $this->currentAbbreviation = $abbreviation;
            $this->currentMeaning      = $meaning;

            $inline['element'] = $this->elementApplyRecursiveDepthFirst(
                'insertAbbreviation',
                $inline['element']
            );
        }

        return $inline;
    }

    /**
     * Handle block list
     *
     * @param array $line  Line data
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function addDdElement(array $line, array $block) : array
    {
        $text = \substr($line['text'], 1);
        $text = \trim($text);

        unset($block['dd']);

        $block['dd'] = [
            'name'    => 'dd',
            'handler' => [
                'function'    => 'lineElements',
                'argument'    => $text,
                'destination' => 'elements',
            ],
        ];

        if (isset($block['interrupted'])) {
            $block['dd']['handler']['function'] = 'textElements';

            unset($block['interrupted']);
        }

        $block['element']['elements'][] = &$block['dd'];

        return $block;
    }

    /**
     * Create footnotes
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function buildFootnoteElement() : array
    {
        $element = [
            'name'       => 'div',
            'attributes' => ['class' => 'footnotes'],
            'elements'   => [
                ['name' => 'hr'],
                [
                    'name'     => 'ol',
                    'elements' => [],
                ],
            ],
        ];

        \uasort($this->definitionData['Footnote'], [self::class, 'sortFootnotes']);

        foreach ($this->definitionData['Footnote'] as $definitionId => $definitionData) {
            if (!isset($definitionData['number'])) {
                continue;
            }

            $text             = $definitionData['text'];
            $textElements     = $this->textElements($text);
            $numbers          = \range(1, $definitionData['count']);
            $backLinkElements = [];

            foreach ($numbers as $number) {
                $backLinkElements[] = ['text' => ' '];
                $backLinkElements[] = [
                    'name'       => 'a',
                    'attributes' => [
                        'href'  => "#fnref{$number}:{$definitionId}",
                        'rev'   => 'footnote',
                        'class' => 'footnote-backref',
                    ],
                    'rawHtml'                => '&#8617;',
                    'allowRawHtmlInSafeMode' => true,
                    'autobreak'              => false,
                ];
            }

            unset($backLinkElements[0]);

            $n = \count($textElements) - 1;

            if ($textElements[$n]['name'] === 'p') {
                $backLinkElements = \array_merge(
                    [
                        [
                            'rawHtml'                => '&#160;',
                            'allowRawHtmlInSafeMode' => true,
                        ],
                    ],
                    $backLinkElements
                );

                unset($textElements[$n]['name']);

                $textElements[$n] = [
                    'name'     => 'p',
                    'elements' => \array_merge(
                        [$textElements[$n]],
                        $backLinkElements
                    ),
                ];
            } else {
                $textElements[] = [
                    'name'     => 'p',
                    'elements' => $backLinkElements,
                ];
            }

            $element['elements'][1]['elements'][] = [
                'name'       => 'li',
                'attributes' => ['id' => 'fn:' . $definitionId],
                'elements'   => \array_merge(
                    $textElements
                ),
            ];
        }

        return $element;
    }

    /**
     * Handle markup/html.
     *
     * Ensures that html is well formed.
     *
     * This function is called recursively
     *
     * @param string $elementMarkup Markup
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function processTag(string $elementMarkup) : string
    {
        // http://stackoverflow.com/q/1148928/200145
        \libxml_use_internal_errors(true);

        $dom = new \DOMDocument();

        // http://stackoverflow.com/q/11309194/200145
        // $elementMarkup = \mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8'); // Deprecated
        $elementMarkup = \mb_encode_numericentity($elementMarkup, [0x80, 0x10FFFF, 0, ~0], 'UTF-8');

        // http://stackoverflow.com/q/4879946/200145
        $dom->loadHTML($elementMarkup);

        if ($dom->documentElement === null) {
            return '';
        }

        if ($dom->doctype !== null) {
            $dom->removeChild($dom->doctype);
        }

        if ($dom->firstChild !== null && $dom->firstChild->firstChild?->firstChild !== null) {
            $dom->replaceChild($dom->firstChild->firstChild->firstChild, $dom->firstChild);
        }

        $elementText = '';

        if ($dom->documentElement->getAttribute('markdown') === '1') {
            foreach ($dom->documentElement->childNodes as $node) {
                $elementText .= $dom->saveHTML($node);
            }

            $dom->documentElement->removeAttribute('markdown');

            $elementText = "\n" . $this->text($elementText) . "\n";
        } else {
            foreach ($dom->documentElement->childNodes as $node) {
                $nodeMarkup = $dom->saveHTML($node);
                if ($nodeMarkup === false) {
                    $nodeMarkup = '';
                }

                $elementText .= $node instanceof \DOMElement && !\in_array($node->nodeName, $this->textLevelElements)
                    ? $this->processTag($nodeMarkup)
                    : $nodeMarkup;
            }
        }

        // because we don't want for markup to get encoded
        $dom->documentElement->nodeValue = 'placeholder\x1A';

        $markup = $dom->saveHTML($dom->documentElement);
        if ($markup === false) {
            return '';
        }

        return \str_replace('placeholder\x1A', $elementText, $markup);
    }

    /**
     * Footnote sort function
     *
     * @param array $a First element
     * @param array $b Second element
     *
     * @return int
     *
     * @since 1.0.0
     */
    protected function sortFootnotes(array $a, array $b) : int
    {
        return $a['number'] <=> $b['number'];
    }

    /**
     * Parse text elements to lines and then handle lines.
     *
     * @param string $text Text to parse
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function textElements(string $text) : array
    {
        // make sure no definitions are set
        $this->definitionData = [];

        // standardize line breaks
        $text = \str_replace(["\r\n", "\r"], "\n", $text);

        // remove surrounding line breaks
        $text = \trim($text, "\n");

        // split text into lines
        $lines = \explode("\n", $text);

        // iterate through lines to identify blocks
        return $this->linesElements($lines);
    }

    /**
     * Handle lines of elements
     *
     * @param string[] $lines Lines to parse
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function linesElements(array $lines) : array
    {
        $elements     = [];
        $currentBlock = null;

        foreach ($lines as $line) {
            if (\rtrim($line) === '') {
                if (isset($currentBlock)) {
                    $currentBlock['interrupted'] = (isset($currentBlock['interrupted'])
                        ? $currentBlock['interrupted'] + 1 : 1
                    );
                }

                continue;
            }

            while (($beforeTab = \strstr($line, "\t", true)) !== false) {
                $shortage = 4 - \mb_strlen($beforeTab, 'utf-8') % 4;

                $line = $beforeTab
                    . \str_repeat(' ', $shortage)
                    . \substr($line, \strlen($beforeTab) + 1);
            }

            $indent = \strspn($line, ' ');
            $text   = $indent > 0 ? \substr($line, $indent) : $line;
            $line   = ['body' => $line, 'indent' => $indent, 'text' => $text];

            if (isset($currentBlock['continuable'])) {
                $methodName = 'block' . $currentBlock['type'] . 'Continue';
                $block      = $this->{$methodName}($line, $currentBlock);

                if (isset($block)) {
                    $currentBlock = $block;
                    continue;
                } elseif (\in_array($currentBlock['type'], self::COMPLETABLE)) {
                    $methodName   = 'block' . $currentBlock['type'] . 'Complete';
                    $currentBlock = $this->{$methodName}($currentBlock);
                }
            }

            $marker     = $text[0];
            $blockTypes = $this->unmarkedBlockTypes;

            if (isset($this->blockTypes[$marker])) {
                foreach ($this->blockTypes[$marker] as $blockType) {
                    $blockTypes [] = $blockType;
                }
            }

            foreach ($blockTypes as $blockType) {
                $block = $this->{"block{$blockType}"}($line, $currentBlock);

                if (isset($block)) {
                    $block['type'] = $blockType;

                    if (!isset($block['identified'])) {
                        if (isset($currentBlock)) {
                            $elements[] = $this->extractElement($currentBlock);
                        }

                        $block['identified'] = true;
                    }

                    if (\in_array($blockType, self::CONTINUABLE)) {
                        $block['continuable'] = true;
                    }

                    $currentBlock = $block;

                    continue 2;
                }
            }

            if (isset($currentBlock) && $currentBlock['type'] === 'Paragraph') {
                $block = $this->paragraphContinue($line, $currentBlock);
            }

            if (isset($block)) {
                $currentBlock = $block;
            } else {
                if (isset($currentBlock)) {
                    $elements[] = $this->extractElement($currentBlock);
                }

                $currentBlock = [
                    'type'    => 'Paragraph',
                    'element' => [
                        'name'    => 'p',
                        'handler' => [
                            'function'    => 'lineElements',
                            'argument'    => $line['text'],
                            'destination' => 'elements',
                        ],
                    ],
                ];

                $currentBlock['identified'] = true;
            }
        }

        if (isset($currentBlock['continuable']) && \in_array($currentBlock['type'], self::COMPLETABLE)) {
            $methodName   = 'block' . $currentBlock['type'] . 'Complete';
            $currentBlock = $this->{$methodName}($currentBlock);
        }

        if (isset($currentBlock)) {
            $elements[] = $this->extractElement($currentBlock);
        }

        return $elements;
    }

    /**
     * Extract element from block
     *
     * @param array $block Block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function extractElement(array $block) : array
    {
        if (!isset($block['element'])) {
            if (isset($block['markup'])) {
                $block['element'] = ['rawHtml' => $block['markup']];
            } elseif (isset($block['hidden'])) {
                $block['element'] = [];
            }
        }

        return $block['element'];
    }

    /**
     * Continue block code
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockCodeContinue(array $line, array $block) : ?array
    {
        if ($line['indent'] < 4) {
            return null;
        }

        if (isset($block['interrupted'])) {
            $block['element']['element']['text'] .= \str_repeat("\n", $block['interrupted']);

            unset($block['interrupted']);
        }

        $block['element']['element']['text'] .= "\n";

        $text = \substr($line['body'], 4);

        $block['element']['element']['text'] .= $text;

        return $block;
    }

    /**
     * Complete block code
     *
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function blockCodeComplete(array $block) : array
    {
        return $block;
    }

    /**
     * Continue block code
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockCommentContinue(array $line, array $block) : ?array
    {
        if (isset($block['closed'])) {
            return null;
        }

        $block['element']['rawHtml'] .= "\n" . $line['body'];

        if (\strpos($line['text'], '-->') !== false) {
            $block['closed'] = true;
        }

        return $block;
    }

    /**
     * Continue block fenced code
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockFencedCodeContinue(array $line, array $block) : ?array
    {
        if (isset($block['complete'])) {
            return null;
        }

        if (isset($block['interrupted'])) {
            $block['element']['element']['text'] .= \str_repeat("\n", $block['interrupted']);

            unset($block['interrupted']);
        }

        if (($len = \strspn($line['text'], $block['char'])) >= $block['openerLength']
            && \rtrim(\substr($line['text'], $len), ' ') === ''
        ) {
            $block['element']['element']['text'] = \substr($block['element']['element']['text'], 1);

            $block['complete'] = true;

            return $block;
        }

        $block['element']['element']['text'] .= "\n" . $line['body'];

        return $block;
    }

    /**
     * Complete block fenced code
     *
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function blockFencedCodeComplete(array $block) : array
    {
        return $block;
    }

    /**
     * Continue block spoiler
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockSpoilerContinue(array $line, array $block) : ?array
    {
        if (isset($block['complete'])) {
            return null;
        }

        if (isset($block['interrupted'])) {
            $block['element']['element']['text'] .= \str_repeat("\n", $block['interrupted']);

            unset($block['interrupted']);
        }

        if (($len = \strspn($line['text'], $block['char'])) >= $block['openerLength']
            && \rtrim(\substr($line['text'], $len), ' ') === ''
        ) {
            $block['element']['element']['text'] = \substr($block['element']['element']['text'], 1);

            $block['complete'] = true;

            return $block;
        }

        $block['element']['element']['elements'][1]['text'] .= "\n" . $line['body'];

        return $block;
    }

    /**
     * Complete block spoiler
     *
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function blockSpoilerComplete(array $block) : array
    {
        return $block;
    }

    /**
     * Continue block list
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockListContinue(array $line, array $block) : ?array
    {
        if (isset($block['interrupted']) && empty($block['li']['handler']['argument'])) {
            return null;
        }

        $requiredIndent = $block['indent'] + \strlen($block['data']['marker']);

        if ($line['indent'] < $requiredIndent
            && (($block['data']['type'] === 'ol'
                && \preg_match('/^[0-9]++' . $block['data']['markerTypeRegex'] . '(?:[ ]++(.*)|$)/', $line['text'], $matches)
            ) || ($block['data']['type'] === 'ul'
                && \preg_match('/^' . $block['data']['markerTypeRegex'] . '(?:[ ]++(.*)|$)/', $line['text'], $matches)
            ))
        ) {
            if (isset($block['interrupted'])) {
                $block['li']['handler']['argument'][] = '';

                $block['loose'] = true;

                unset($block['interrupted']);
            }

            unset($block['li']);

            $text = $matches[1] ?? '';

            $block['indent'] = $line['indent'];

            $block['li'] = [
                'name'    => 'li',
                'handler' => [
                    'function'    => 'li',
                    'argument'    => [$text],
                    'destination' => 'elements',
                ],
            ];

            $block['element']['elements'][] = &$block['li'];

            return $block;
        } elseif ($line['indent'] < $requiredIndent && $this->blockList($line)) {
            return null;
        }

        if ($line['text'][0] === '[' && $this->blockReference($line)) {
            return $block;
        }

        if ($line['indent'] >= $requiredIndent) {
            if (isset($block['interrupted'])) {
                $block['li']['handler']['argument'][] = '';

                $block['loose'] = true;

                unset($block['interrupted']);
            }

            $text = \substr($line['body'], $requiredIndent);

            $block['li']['handler']['argument'][] = $text;

            return $block;
        }

        if (!isset($block['interrupted'])) {
            $text = \preg_replace('/^[ ]{0,' . $requiredIndent . '}+/', '', $line['body']);

            $block['li']['handler']['argument'][] = $text;

            return $block;
        }

        return null;
    }

    /**
     * Complete block list
     *
     * @param array $block Current block
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function blockListComplete(array $block) : array
    {
        if (!isset($block['loose'])) {
            return $block;
        }

        foreach ($block['element']['elements'] as &$li) {
            if (\end($li['handler']['argument']) !== '') {
                $li['handler']['argument'][] = '';
            }
        }

        return $block;
    }

    /**
     * Continue block quote
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockQuoteContinue(array $line, array $block) : ?array
    {
        if (isset($block['interrupted'])) {
            return null;
        }

        if ($line['text'][0] === '>' && \preg_match('/^>[ ]?+(.*+)/', $line['text'], $matches)) {
            $block['element']['handler']['argument'][] = $matches[1];

            return $block;
        }

        if (!isset($block['interrupted'])) {
            $block['element']['handler']['argument'][] = $line['text'];

            return $block;
        }

        return null;
    }

    /**
     * Continue block table
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function blockTableContinue(array $line, array $block) : ?array
    {
        if (isset($block['interrupted'])
            || (\count($block['alignments']) !== 1 && $line['text'][0] !== '|' && \strpos($line['text'], '|') === false)
        ) {
            return null;
        }

        $elements = [];

        $row = $line['text'];

        $row = \trim($row);
        $row = \trim($row, '|');

        \preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches);

        $cells = \array_slice($matches[0], 0, \count($block['alignments']));

        foreach ($cells as $index => $cell) {
            $cell = \trim($cell);

            $element = [
                'name'    => 'td',
                'handler' => [
                    'function'    => 'lineElements',
                    'argument'    => $cell,
                    'destination' => 'elements',
                ],
            ];

            if (isset($block['alignments'][$index])) {
                $element['attributes'] = [
                    'style' => 'text-align: ' . $block['alignments'][$index] . ';',
                ];
            }

            $elements [] = $element;
        }

        $element = [
            'name'     => 'tr',
            'elements' => $elements,
        ];

        $block['element']['elements'][1]['elements'][] = $element;

        return $block;
    }

    /**
     * Continue block paragraph
     *
     * @param array{body:string, indent:int, text:string} $line  Line data
     * @param array                                       $block Current block
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function paragraphContinue(array $line, array $block) : ?array
    {
        if (isset($block['interrupted'])) {
            return null;
        }

        $block['element']['handler']['argument'] .= "\n".$line['text'];

        return $block;
    }

    /**
     * Handle link
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function inlineLinkParent(array $excerpt) : ?array
    {
        $element = [
            'name'    => 'a',
            'handler' => [
                'function'    => 'lineElements',
                'argument'    => null,
                'destination' => 'elements',
            ],
            'nonNestables' => ['Url', 'Link'],
            'attributes'   => [
                'href'  => null,
                'title' => null,
            ],
        ];

        $extent    = 0;
        $remainder = $excerpt['text'];

        if (\preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) {
            $element['handler']['argument'] = $matches[1];

            $extent += \strlen($matches[0]);

            $remainder = \substr($remainder, $extent);
        } else {
            return null;
        }

        if (\preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) {
            $element['attributes']['href'] = UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[1]));

            if (isset($matches[2])) {
                $element['attributes']['title'] = \substr($matches[2], 1, - 1);
            }

            $extent += \strlen($matches[0]);
        } else {
            if (\preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) {
                $definition = \strlen($matches[1]) !== 0 ? $matches[1] : $element['handler']['argument'];
                $definition = \strtolower($definition);

                $extent += \strlen($matches[0]);
            } else {
                $definition = \strtolower($element['handler']['argument']);
            }

            if (!isset($this->definitionData['Reference'][$definition])) {
                return null;
            }

            $definition = $this->definitionData['Reference'][$definition];

            $element['attributes']['href']  = $definition['url'];
            $element['attributes']['title'] = $definition['title'];
        }

        return [
            'extent'  => $extent,
            'element' => $element,
        ];
    }

    /**
     * Handle special character
     *
     * @param array{text:string, context:string, before:string} $excerpt Inline data
     *
     * @return null|array
     *
     * @since 1.0.0
     */
    protected function inlineSpecialCharacter(array $excerpt) : ?array
    {
        if (\substr($excerpt['text'], 1, 1) !== ' ' && \strpos($excerpt['text'], ';') !== false
            && \preg_match('/^&(#?+[0-9a-zA-Z]++);/', $excerpt['text'], $matches)
        ) {
            return [
                'element' => ['rawHtml' => '&' . $matches[1] . ';'],
                'extent'  => \strlen($matches[0]),
            ];
        }

        return null;
    }

    /**
     * Handle "handler"
     *
     * @param array $element Element to handle
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function handle(array $element) : array
    {
        if (!isset($element['handler'])) {
            return $element;
        }

        if (!isset($element['nonNestables'])) {
            $element['nonNestables'] = [];
        }

        if (\is_string($element['handler'])) {
            $function = $element['handler'];
            $argument = $element['text'];
            unset($element['text']);
            $destination = 'rawHtml';
        } else {
            $function    = $element['handler']['function'];
            $argument    = $element['handler']['argument'];
            $destination = $element['handler']['destination'];
        }

        $element[$destination] = $this->{$function}($argument, $element['nonNestables']);

        if ($destination === 'handler') {
            $element = $this->handle($element);
        }

        unset($element['handler']);

        return $element;
    }

    /**
     * Handle element recursively
     *
     * @param string|\Closure $closure Closure for handling element
     * @param array           $element Element to handle
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function elementApplyRecursiveDepthFirst(string|\Closure $closure, array $element) : array
    {
        if (isset($element['elements'])) {
            foreach ($element['elements'] as &$e) {
                $e = $this->elementApplyRecursiveDepthFirst($closure, $e);
            }
        } elseif (isset($element['element'])) {
            foreach ($element['element'] as &$e) {
                $e = $this->elementApplyRecursiveDepthFirst($closure, $e);
            }
        }

        return \is_string($closure) ? $this->{$closure}($element) : $closure($element);
    }

    /**
     * Render element
     *
     * @param array $element Element to render
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function element(array $element) : string
    {
        if ($this->safeMode) {
            $element = $this->sanitizeElement($element);
        }

        // identity map if element has no handler
        $element = $this->handle($element);
        $hasName = isset($element['name']);
        $markup  = '';

        if ($hasName) {
            $markup .= '<' . $element['name'];

            if (isset($element['attributes'])) {
                foreach ($element['attributes'] as $name => $value) {
                    if ($value === null) {
                        continue;
                    }

                    $markup .= ' ' . $name . '="' . \htmlspecialchars((string) $value, \ENT_QUOTES, 'UTF-8') . '"';
                }
            }
        }

        $permitRawHtml = false;
        $text          = null;

        if (isset($element['text'])) {
            $text = $element['text'];
        } elseif (isset($element['rawHtml'])) {
            // very strongly consider an alternative if you're writing an extension
            $text = $element['rawHtml'];

            $permitRawHtml = !$this->safeMode || ($element['allowRawHtmlInSafeMode'] ?? false);
        }

        $hasContent = isset($text) || isset($element['element']) || isset($element['elements']);

        if ($hasContent) {
            $markup .= $hasName ? '>' : '';

            if (isset($element['elements'])) {
                $markup .= $this->elements($element['elements']);
            } elseif (isset($element['element'])) {
                $markup .= $this->element($element['element']);
            } elseif (!$permitRawHtml) {
                $markup .= \htmlspecialchars((string) $text, \ENT_NOQUOTES, 'UTF-8');
            } else {
                $markup .= $text;
            }

            $markup .= $hasName ? '</' . $element['name'] . '>' : '';
        } elseif ($hasName) {
            $markup .= ' />';
        }

        return $markup;
    }

    /**
     * Render elements
     *
     * @param array $elements Elements to render
     *
     * @return string
     *
     * @since 1.0.0
     */
    protected function elements(array $elements) : string
    {
        $markup    = '';
        $autoBreak = true;

        foreach ($elements as $element) {
            if (empty($element)) {
                continue;
            }

            $autoBreakNext = (isset($element['autobreak'])
                ? $element['autobreak'] : isset($element['name'])
            );

            // (autobreak === false) covers both sides of an element
            $autoBreak = $autoBreak ? $autoBreakNext : $autoBreak;

            $markup .= ($autoBreak ? "\n" : '') . $this->element($element);
            $autoBreak = $autoBreakNext;
        }

        return $markup . ($autoBreak ? "\n" : '');
    }

    /**
     * Handle list
     *
     * @param string[] $lines Lines
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function li(array $lines) : array
    {
        $elements = $this->linesElements($lines);

        if (!\in_array('', $lines)
            && isset($elements[0], $elements[0]['name'])
            && $elements[0]['name'] === 'p'
        ) {
            unset($elements[0]['name']);
        }

        return $elements;
    }

    /**
     * Replace occurrences $regexp with $elements in $text.
     *
     * @param string $regexp   Regex
     * @param array  $elements Elements to replace
     * @param string $text     Text to match against regex
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected static function pregReplaceElements(string $regexp, array $elements, string $text) : array
    {
        $newElements = [];

        while (\preg_match($regexp, $text, $matches, \PREG_OFFSET_CAPTURE)) {
            $offset = (int) $matches[0][1];
            $before = \substr($text, 0, $offset);
            $after  = \substr($text, $offset + \strlen($matches[0][0]));

            $newElements[] = ['text' => $before];

            foreach ($elements as $element) {
                $newElements[] = $element;
            }

            $text = $after;
        }

        $newElements[] = ['text' => $text];

        return $newElements;
    }

    /**
     * Sanitize element
     *
     * @param array $element Element to sanitize
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function sanitizeElement(array $element) : array
    {
        static $goodAttribute    = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
        static $safeUrlNameToAtt = [
            'a'   => 'href',
            'img' => 'src',
        ];

        if (!isset($element['name'])) {
            unset($element['attributes']);

            return $element;
        }

        if (isset($safeUrlNameToAtt[$element['name']])) {
            $element = $this->filterUnsafeUrlInAttribute($element, $safeUrlNameToAtt[$element['name']]);
        }

        if (!empty($element['attributes'])) {
            foreach ($element['attributes'] as $att => $_) {
                if (!\preg_match($goodAttribute, $att)) {
                    // filter out badly parsed attribute
                    unset($element['attributes'][$att]);
                } elseif (\str_starts_with($att, 'on')) {
                    // dump onevent attribute
                    unset($element['attributes'][$att]);
                }
            }
        }

        return $element;
    }

    /**
     * Sanitize url in attribute
     *
     * @param array  $element   Element to sanitize
     * @param string $attribute Attribute to sanitize
     *
     * @return array
     *
     * @since 1.0.0
     */
    protected function filterUnsafeUrlInAttribute(array $element, string $attribute) : array
    {
        foreach ($this->safeLinksWhitelist as $scheme) {
            if (\str_starts_with($element['attributes'][$attribute], $scheme)) {
                return $element;
            }
        }

        $element['attributes'][$attribute] = \str_replace(':', '%3A', $element['attributes'][$attribute]);

        return $element;
    }
}
