diff --git a/composer.json b/composer.json index 981ce1e..94a167d 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,5 @@ { "require": { - "gch1p/parsedown-highlight": "master", - "gch1p/parsedown-highlight-extended": "dev-main", - "erusev/parsedown": "1.8.0-beta-7", "gigablah/sphinxphp": "2.0.*", "ext-mbstring": "*", "ext-gd": "*", @@ -11,24 +8,15 @@ "ext-yaml": "*", "ext-gmp": "*", "ext-memcached": "*", - "samdark/sitemap": "^2.1", + "samdark/sitemap": "^2.1", "twig/twig": "^3.0" }, - "repositories": [ - { - "url": "https://github.com/gch1p/parsedown-highlight", - "type": "git" - }, - { - "url": "https://github.com/gch1p/ParsedownHighlightExtended", - "type": "git" - } - ], "minimum-stability": "dev", "prefer-stable": true, "preferred-install": "dist", "autoload": { "psr-4": { + "thirdparty\\": "src/thirdparty", "engine\\": "src/engine", "app\\": "src/lib", "app\\foreignone\\": [ diff --git a/composer.lock b/composer.lock index 7e7eb39..2eb1715 100644 --- a/composer.lock +++ b/composer.lock @@ -4,212 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d75fee72f82dcd543f7ac9fd20971de8", + "content-hash": "d7b8938d922dae7a99e858b59cc06654", "packages": [ - { - "name": "erusev/parsedown", - "version": "1.8.0-beta-7", - "source": { - "type": "git", - "url": "https://github.com/erusev/parsedown.git", - "reference": "fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/erusev/parsedown/zipball/fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955", - "reference": "fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35" - }, - "type": "library", - "autoload": { - "psr-0": { - "Parsedown": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Emanuil Rusev", - "email": "hello@erusev.com", - "homepage": "http://erusev.com" - } - ], - "description": "Parser for Markdown.", - "homepage": "http://parsedown.org", - "keywords": [ - "markdown", - "parser" - ], - "support": { - "issues": "https://github.com/erusev/parsedown/issues", - "source": "https://github.com/erusev/parsedown/tree/1.8.0-beta-7" - }, - "time": "2019-03-17T18:47:21+00:00" - }, - { - "name": "erusev/parsedown-extra", - "version": "0.8.1", - "source": { - "type": "git", - "url": "https://github.com/erusev/parsedown-extra.git", - "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/91ac3ff98f0cea243bdccc688df43810f044dcef", - "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef", - "shasum": "" - }, - "require": { - "erusev/parsedown": "^1.7.4" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35" - }, - "type": "library", - "autoload": { - "psr-0": { - "ParsedownExtra": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Emanuil Rusev", - "email": "hello@erusev.com", - "homepage": "http://erusev.com" - } - ], - "description": "An extension of Parsedown that adds support for Markdown Extra.", - "homepage": "https://github.com/erusev/parsedown-extra", - "keywords": [ - "markdown", - "markdown extra", - "parsedown", - "parser" - ], - "support": { - "issues": "https://github.com/erusev/parsedown-extra/issues", - "source": "https://github.com/erusev/parsedown-extra/tree/0.8.x" - }, - "time": "2019-12-30T23:20:37+00:00" - }, - { - "name": "gch1p/parsedown-highlight", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/gch1p/parsedown-highlight", - "reference": "d017545cc221f4becac0f7fc0570304ceabba846" - }, - "require": { - "erusev/parsedown": "1.8.0-beta-7", - "erusev/parsedown-extra": "0.8.1", - "php": ">=7.1", - "scrivo/highlight.php": "^9.14" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.10", - "larapack/dd": "^1.0", - "phpunit/phpunit": "^6.0|^7.0", - "sempro/phpunit-pretty-print": "^1.0" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-4": { - "gch1p\\": "src" - } - }, - "autoload-dev": { - "psr-4": { - "Tests\\": "tests" - } - }, - "scripts": { - "test": [ - "phpunit" - ], - "styles:lint": [ - "php-cs-fixer fix --dry-run --diff" - ], - "styles:fix": [ - "php-cs-fixer fix" - ] - }, - "license": [ - "MIT" - ], - "authors": [ - { - "name": "TJ Miller", - "email": "oss@tjmiller.co", - "homepage": "https://tjmiller.co", - "role": "Developer" - } - ], - "description": "Server side code block rendering for Parsedown", - "homepage": "https://github.com/gch1p/parsedown-highlight", - "keywords": [ - "code", - "markdown", - "parsedown" - ], - "time": "2023-03-01T22:25:56+00:00" - }, - { - "name": "gch1p/parsedown-highlight-extended", - "version": "dev-main", - "source": { - "type": "git", - "url": "https://github.com/gch1p/ParsedownHighlightExtended", - "reference": "e2d9d7eae203680690d61877fbef2dc2df61dd0b" - }, - "require": { - "erusev/parsedown": "^1.8-beta-6", - "erusev/parsedown-extra": "^0.8.0", - "gch1p/parsedown-highlight": "master", - "php": ">=7.2" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-0": { - "ParsedownExtended": "" - } - }, - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Benjamin Hoegh", - "homepage": "https://github.com/benjaminhoegh" - } - ], - "description": "An extension for ParsedownHighlight.", - "homepage": "https://github.com/gch1p/ParsedownHighlightExtended", - "keywords": [ - "markdown", - "markdown extended", - "parsedown", - "parsedown extended" - ], - "time": "2023-03-01T22:30:01+00:00" - }, { "name": "gigablah/sphinxphp", "version": "2.0.8", @@ -324,84 +120,6 @@ ], "time": "2023-11-01T08:41:34+00:00" }, - { - "name": "scrivo/highlight.php", - "version": "v9.18.1.10", - "source": { - "type": "git", - "url": "https://github.com/scrivo/highlight.php.git", - "reference": "850f4b44697a2552e892ffe71490ba2733c2fc6e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/850f4b44697a2552e892ffe71490ba2733c2fc6e", - "reference": "850f4b44697a2552e892ffe71490ba2733c2fc6e", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": ">=5.4" - }, - "require-dev": { - "phpunit/phpunit": "^4.8|^5.7", - "sabberworm/php-css-parser": "^8.3", - "symfony/finder": "^2.8|^3.4|^5.4", - "symfony/var-dumper": "^2.8|^3.4|^5.4" - }, - "suggest": { - "ext-mbstring": "Allows highlighting code with unicode characters and supports language with unicode keywords" - }, - "type": "library", - "autoload": { - "files": [ - "HighlightUtilities/functions.php" - ], - "psr-0": { - "Highlight\\": "", - "HighlightUtilities\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Geert Bergman", - "homepage": "http://www.scrivo.org/", - "role": "Project Author" - }, - { - "name": "Vladimir Jimenez", - "homepage": "https://allejo.io", - "role": "Maintainer" - }, - { - "name": "Martin Folkers", - "homepage": "https://twobrain.io", - "role": "Contributor" - } - ], - "description": "Server side syntax highlighter that supports 185 languages. It's a PHP port of highlight.js", - "keywords": [ - "code", - "highlight", - "highlight.js", - "highlight.php", - "syntax" - ], - "support": { - "issues": "https://github.com/scrivo/highlight.php/issues", - "source": "https://github.com/scrivo/highlight.php" - }, - "funding": [ - { - "url": "https://github.com/allejo", - "type": "github" - } - ], - "time": "2022-12-17T21:53:22+00:00" - }, { "name": "symfony/deprecation-contracts", "version": "v3.5.1", @@ -711,9 +429,7 @@ "packages-dev": [], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "gch1p/parsedown-highlight-extended": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/lib/MyParsedown.php b/src/lib/MyParsedown.php index 0ec942c..08bca49 100644 --- a/src/lib/MyParsedown.php +++ b/src/lib/MyParsedown.php @@ -4,10 +4,9 @@ namespace app; use app\foreignone\PostLanguage; use app\foreignone\Upload; -use ParsedownExtended; class MyParsedown - extends ParsedownExtended + extends \thirdparty\ParsedownExtended { protected array $options; diff --git a/src/thirdparty/Parsedown.php b/src/thirdparty/Parsedown.php new file mode 100644 index 0000000..d97f115 --- /dev/null +++ b/src/thirdparty/Parsedown.php @@ -0,0 +1,1755 @@ +textElements($text); + + # convert to markup + $markup = $this->elements($Elements); + + # trim line breaks + $markup = trim($markup, "\n"); + + return $markup; + } + + protected function textElements($text) { + # make sure no definitions are set + $this->DefinitionData = array(); + + # standardize line breaks + $text = str_replace(array("\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); + } + + # + # Setters + # + + function setBreaksEnabled($breaksEnabled) { + $this->breaksEnabled = $breaksEnabled; + + return $this; + } + + protected $breaksEnabled; + + function setMarkupEscaped($markupEscaped) { + $this->markupEscaped = $markupEscaped; + + return $this; + } + + protected $markupEscaped; + + function setUrlsLinked($urlsLinked) { + $this->urlsLinked = $urlsLinked; + + return $this; + } + + protected $urlsLinked = true; + + function setSafeMode($safeMode) { + $this->safeMode = (bool)$safeMode; + + return $this; + } + + protected $safeMode; + + function setStrictMode($strictMode) { + $this->strictMode = (bool)$strictMode; + + return $this; + } + + protected $strictMode; + + protected $safeLinksWhitelist = array( + 'http://', + 'https://', + 'ftp://', + 'ftps://', + 'mailto:', + 'tel:', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/jpeg;base64,', + 'irc:', + 'ircs:', + 'git:', + 'ssh:', + 'news:', + 'steam:', + ); + + # + # Lines + # + + protected $BlockTypes = array( + '#' => array('Header'), + '*' => array('Rule', 'List'), + '+' => array('List'), + '-' => array('SetextHeader', 'Table', 'Rule', 'List'), + '0' => array('List'), + '1' => array('List'), + '2' => array('List'), + '3' => array('List'), + '4' => array('List'), + '5' => array('List'), + '6' => array('List'), + '7' => array('List'), + '8' => array('List'), + '9' => array('List'), + ':' => array('Table'), + '<' => array('Comment', 'Markup'), + '=' => array('SetextHeader'), + '>' => array('Quote'), + '[' => array('Reference'), + '_' => array('Rule'), + '`' => array('FencedCode'), + '|' => array('Table'), + '~' => array('FencedCode'), + ); + + # ~ + + protected $unmarkedBlockTypes = array( + 'Code', + ); + + # + # Blocks + # + + protected function lines(array $lines) { + return $this->elements($this->linesElements($lines)); + } + + protected function linesElements(array $lines) { + $Elements = array(); + $CurrentBlock = null; + + foreach ($lines as $line) { + if (chop($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 = array('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; + } else { + if ($this->isBlockCompletable($CurrentBlock['type'])) { + $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 ($this->isBlockContinuable($blockType)) { + $Block['continuable'] = true; + } + + $CurrentBlock = $Block; + + continue 2; + } + } + + # ~ + + if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') { + $Block = $this->paragraphContinue($Line, $CurrentBlock); + } + + if (isset($Block)) { + $CurrentBlock = $Block; + } else { + if (isset($CurrentBlock)) { + $Elements[] = $this->extractElement($CurrentBlock); + } + + $CurrentBlock = $this->paragraph($Line); + + $CurrentBlock['identified'] = true; + } + } + + # ~ + + if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) { + $methodName = 'block'.$CurrentBlock['type'].'Complete'; + $CurrentBlock = $this->$methodName($CurrentBlock); + } + + # ~ + + if (isset($CurrentBlock)) { + $Elements[] = $this->extractElement($CurrentBlock); + } + + # ~ + + return $Elements; + } + + protected function extractElement(array $Component) { + if (!isset($Component['element'])) { + if (isset($Component['markup'])) { + $Component['element'] = array('rawHtml' => $Component['markup']); + } elseif (isset($Component['hidden'])) { + $Component['element'] = array(); + } + } + + return $Component['element']; + } + + protected function isBlockContinuable($Type) { + return method_exists($this, 'block'.$Type.'Continue'); + } + + protected function isBlockCompletable($Type) { + return method_exists($this, 'block'.$Type.'Complete'); + } + + # + # Code + + protected function blockCode($Line, $Block = null) { + if (isset($Block) and $Block['type'] === 'Paragraph' and !isset($Block['interrupted'])) { + return; + } + + if ($Line['indent'] >= 4) { + $text = substr($Line['body'], 4); + + $Block = array( + 'element' => array( + 'name' => 'pre', + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ), + ); + + return $Block; + } + } + + protected function blockCodeContinue($Line, $Block) { + if ($Line['indent'] >= 4) { + 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; + } + } + + protected function blockCodeComplete($Block) { + return $Block; + } + + # + # Comment + + protected function blockComment($Line) { + if ($this->markupEscaped or $this->safeMode) { + return; + } + + if (strpos($Line['text'], '') !== false) { + $Block['closed'] = true; + } + + return $Block; + } + } + + protected function blockCommentContinue($Line, array $Block) { + if (isset($Block['closed'])) { + return; + } + + $Block['element']['rawHtml'] .= "\n".$Line['body']; + + if (strpos($Line['text'], '-->') !== false) { + $Block['closed'] = true; + } + + return $Block; + } + + # + # Fenced Code + + protected function blockFencedCode($Line) { + $marker = $Line['text'][0]; + + $openerLength = strspn($Line['text'], $marker); + + if ($openerLength < 3) { + return; + } + + $infostring = trim(substr($Line['text'], $openerLength), "\t "); + + if (strpos($infostring, '`') !== false) { + return; + } + + $Element = array( + 'name' => 'code', + 'text' => '', + ); + + if ($infostring !== '') { + /** + * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes + * Every HTML element may have a class attribute specified. + * The attribute, if specified, must have a value that is a set + * of space-separated tokens representing the various classes + * that the element belongs to. + * [...] + * The space characters, for the purposes of this specification, + * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), + * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and + * U+000D CARRIAGE RETURN (CR). + */ + $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r")); + + $Element['attributes'] = array('class' => "language-$language"); + } + + $Block = array( + 'char' => $marker, + 'openerLength' => $openerLength, + 'element' => array( + 'name' => 'pre', + 'element' => $Element, + ), + ); + + return $Block; + } + + protected function blockFencedCodeContinue($Line, $Block) { + if (isset($Block['complete'])) { + return; + } + + 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'] + and chop(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; + } + + protected function blockFencedCodeComplete($Block) { + return $Block; + } + + # + # Header + + protected function blockHeader($Line) { + $level = strspn($Line['text'], '#'); + + if ($level > 6) { + return; + } + + $text = trim($Line['text'], '#'); + + if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') { + return; + } + + $text = trim($text, ' '); + + $Block = array( + 'element' => array( + 'name' => 'h'.$level, + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $text, + 'destination' => 'elements', + ) + ), + ); + + return $Block; + } + + # + # List + + protected function blockList($Line, ?array $CurrentBlock = null) { + list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]'); + + if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches)) { + $contentIndent = strlen($matches[2]); + + if ($contentIndent >= 5) { + $contentIndent -= 1; + $matches[1] = substr($matches[1], 0, -$contentIndent); + $matches[3] = str_repeat(' ', $contentIndent).$matches[3]; + } elseif ($contentIndent === 0) { + $matches[1] .= ' '; + } + + $markerWithoutWhitespace = strstr($matches[1], ' ', true); + + $Block = array( + 'indent' => $Line['indent'], + 'pattern' => $pattern, + 'data' => array( + 'type' => $name, + 'marker' => $matches[1], + 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)), + ), + 'element' => array( + 'name' => $name, + 'elements' => array(), + ), + ); + $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/'); + + if ($name === 'ol') { + $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0'; + + if ($listStart !== '1') { + if ( + isset($CurrentBlock) + and $CurrentBlock['type'] === 'Paragraph' + and !isset($CurrentBlock['interrupted']) + ) { + return; + } + + $Block['element']['attributes'] = array('start' => $listStart); + } + } + + $Block['li'] = array( + 'name' => 'li', + 'handler' => array( + 'function' => 'li', + 'argument' => !empty($matches[3]) ? array($matches[3]) : array(), + 'destination' => 'elements' + ) + ); + + $Block['element']['elements'] [] = &$Block['li']; + + return $Block; + } + } + + protected function blockListContinue($Line, array $Block) { + if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) { + return null; + } + + $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker'])); + + if ($Line['indent'] < $requiredIndent + and ( + ( + $Block['data']['type'] === 'ol' + and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) + ) or ( + $Block['data']['type'] === 'ul' + and 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 = isset($matches[1]) ? $matches[1] : ''; + + $Block['indent'] = $Line['indent']; + + $Block['li'] = array( + 'name' => 'li', + 'handler' => array( + 'function' => 'li', + 'argument' => array($text), + 'destination' => 'elements' + ) + ); + + $Block['element']['elements'] [] = &$Block['li']; + + return $Block; + } elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) { + return null; + } + + if ($Line['text'][0] === '[' and $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; + } + } + + protected function blockListComplete(array $Block) { + if (isset($Block['loose'])) { + foreach ($Block['element']['elements'] as &$li) { + if (end($li['handler']['argument']) !== '') { + $li['handler']['argument'] [] = ''; + } + } + } + + return $Block; + } + + # + # Quote + + protected function blockQuote($Line) { + if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) { + $Block = array( + 'element' => array( + 'name' => 'blockquote', + 'handler' => array( + 'function' => 'linesElements', + 'argument' => (array)$matches[1], + 'destination' => 'elements', + ) + ), + ); + + return $Block; + } + } + + protected function blockQuoteContinue($Line, array $Block) { + if (isset($Block['interrupted'])) { + return; + } + + if ($Line['text'][0] === '>' and 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; + } + } + + # + # Rule + + protected function blockRule($Line) { + $marker = $Line['text'][0]; + + if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') { + $Block = array( + 'element' => array( + 'name' => 'hr', + ), + ); + + return $Block; + } + } + + # + # Setext + + protected function blockSetextHeader($Line, ?array $Block = null) { + if (!isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) { + return; + } + + if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') { + $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; + + return $Block; + } + } + + # + # Markup + + protected function blockMarkup($Line) { + if ($this->markupEscaped or $this->safeMode) { + return; + } + + if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches)) { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) { + return; + } + + $Block = array( + 'name' => $matches[1], + 'element' => array( + 'rawHtml' => $Line['text'], + 'autobreak' => true, + ), + ); + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) { + if (isset($Block['closed']) or isset($Block['interrupted'])) { + return; + } + + $Block['element']['rawHtml'] .= "\n".$Line['body']; + + return $Block; + } + + # + # Reference + + protected function blockReference($Line) { + if (strpos($Line['text'], ']') !== false + and preg_match('/^\[(.+?)\]:[ ]*+?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches) + ) { + $id = strtolower($matches[1]); + + $Data = array( + 'url' => $matches[2], + 'title' => isset($matches[3]) ? $matches[3] : null, + ); + + $this->DefinitionData['Reference'][$id] = $Data; + + $Block = array( + 'element' => array(), + ); + + return $Block; + } + } + + # + # Table + + protected function blockTable($Line, ?array $Block = null) { + if (!isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) { + return; + } + + if ( + strpos($Block['element']['handler']['argument'], '|') === false + and strpos($Line['text'], '|') === false + and strpos($Line['text'], ':') === false + or strpos($Block['element']['handler']['argument'], "\n") !== false + ) { + return; + } + + if (chop($Line['text'], ' -:|') !== '') { + return; + } + + $alignments = array(); + + $divider = $Line['text']; + + $divider = trim($divider); + $divider = trim($divider, '|'); + + $dividerCells = explode('|', $divider); + + foreach ($dividerCells as $dividerCell) { + $dividerCell = trim($dividerCell); + + if ($dividerCell === '') { + return; + } + + $alignment = null; + + if ($dividerCell[0] === ':') { + $alignment = 'left'; + } + + if (substr($dividerCell, -1) === ':') { + $alignment = $alignment === 'left' ? 'center' : 'right'; + } + + $alignments [] = $alignment; + } + + # ~ + + $HeaderElements = array(); + + $header = $Block['element']['handler']['argument']; + + $header = trim($header); + $header = trim($header, '|'); + + $headerCells = explode('|', $header); + + if (count($headerCells) !== count($alignments)) { + return; + } + + foreach ($headerCells as $index => $headerCell) { + $headerCell = trim($headerCell); + + $HeaderElement = array( + 'name' => 'th', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $headerCell, + 'destination' => 'elements', + ) + ); + + if (isset($alignments[$index])) { + $alignment = $alignments[$index]; + + $HeaderElement['attributes'] = array( + 'style' => "text-align: $alignment;", + ); + } + + $HeaderElements [] = $HeaderElement; + } + + # ~ + + $Block = array( + 'alignments' => $alignments, + 'identified' => true, + 'element' => array( + 'name' => 'table', + 'elements' => array(), + ), + ); + + $Block['element']['elements'] [] = array( + 'name' => 'thead', + ); + + $Block['element']['elements'] [] = array( + 'name' => 'tbody', + 'elements' => array(), + ); + + $Block['element']['elements'][0]['elements'] [] = array( + 'name' => 'tr', + 'elements' => $HeaderElements, + ); + + return $Block; + } + + protected function blockTableContinue($Line, array $Block) { + if (isset($Block['interrupted'])) { + return; + } + + if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) { + $Elements = array(); + + $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 = array( + 'name' => 'td', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $cell, + 'destination' => 'elements', + ) + ); + + if (isset($Block['alignments'][$index])) { + $Element['attributes'] = array( + 'style' => 'text-align: '.$Block['alignments'][$index].';', + ); + } + + $Elements [] = $Element; + } + + $Element = array( + 'name' => 'tr', + 'elements' => $Elements, + ); + + $Block['element']['elements'][1]['elements'] [] = $Element; + + return $Block; + } + } + + # + # ~ + # + + protected function paragraph($Line) { + return array( + 'type' => 'Paragraph', + 'element' => array( + 'name' => 'p', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $Line['text'], + 'destination' => 'elements', + ), + ), + ); + } + + protected function paragraphContinue($Line, array $Block) { + if (isset($Block['interrupted'])) { + return; + } + + $Block['element']['handler']['argument'] .= "\n".$Line['text']; + + return $Block; + } + + # + # Inline Elements + # + + protected $InlineTypes = array( + '!' => array('Image'), + '&' => array('SpecialCharacter'), + '*' => array('Emphasis'), + ':' => array('Url'), + '<' => array('UrlTag', 'EmailTag', 'Markup'), + '[' => array('Link'), + '_' => array('Emphasis'), + '`' => array('Code'), + '~' => array('Strikethrough'), + '\\' => array('EscapeSequence'), + ); + + # ~ + + protected $inlineMarkerList = '!*_&[:<`~\\'; + + # + # ~ + # + + public function line($text, $nonNestables = array()) { + return $this->elements($this->lineElements($text, $nonNestables)); + } + + protected function lineElements($text, $nonNestables = array()) { + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + $Elements = array(); + + $nonNestables = (empty($nonNestables) + ? array() + : array_combine($nonNestables, $nonNestables) + ); + + # $excerpt is based on the first occurrence of a marker + + while ($excerpt = strpbrk($text, $this->inlineMarkerList)) { + $marker = $excerpt[0]; + + $markerPosition = strlen($text) - strlen($excerpt); + + $Excerpt = array('text' => $excerpt, 'context' => $text); + + 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 (!isset($Inline)) { + continue; + } + + # makes sure that the inline belongs to "our" marker + + if (isset($Inline['position']) and $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; + } + + # + # ~ + # + + protected function inlineText($text) { + $Inline = array( + 'extent' => strlen($text), + 'element' => array(), + ); + + $Inline['element']['elements'] = self::pregReplaceElements( + $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', + array( + array('name' => 'br'), + array('text' => "\n"), + ), + $text + ); + + return $Inline; + } + + protected function inlineCode($Excerpt) { + $marker = $Excerpt['text'][0]; + + if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]), + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ); + } + } + + protected function inlineEmailTag($Excerpt) { + $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 + and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches) + ) { + $url = $matches[1]; + + if (!isset($matches[2])) { + $url = "mailto:$url"; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $matches[1], + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + protected function inlineEmphasis($Excerpt) { + if (!isset($Excerpt['text'][1])) { + return; + } + + $marker = $Excerpt['text'][0]; + + if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) { + $emphasis = 'strong'; + } elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) { + $emphasis = 'em'; + } else { + return; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => $emphasis, + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $matches[1], + 'destination' => 'elements', + ) + ), + ); + } + + protected function inlineEscapeSequence($Excerpt) { + if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) { + return array( + 'element' => array('rawHtml' => $Excerpt['text'][1]), + 'extent' => 2, + ); + } + } + + protected function inlineImage($Excerpt) { + if (!isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') { + return; + } + + $Excerpt['text'] = substr($Excerpt['text'], 1); + + $Link = $this->inlineLink($Excerpt); + + if ($Link === null) { + return; + } + + $Inline = array( + 'extent' => $Link['extent'] + 1, + 'element' => array( + 'name' => 'img', + 'attributes' => array( + '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; + } + + protected function inlineLink($Excerpt) { + $Element = array( + 'name' => 'a', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => null, + 'destination' => 'elements', + ), + 'nonNestables' => array('Url', 'Link'), + 'attributes' => array( + '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; + } + + if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) { + $Element['attributes']['href'] = $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]) ? $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; + } + + $Definition = $this->DefinitionData['Reference'][$definition]; + + $Element['attributes']['href'] = $Definition['url']; + $Element['attributes']['title'] = $Definition['title']; + } + + return array( + 'extent' => $extent, + 'element' => $Element, + ); + } + + protected function inlineMarkup($Excerpt) { + if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) { + return; + } + + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + } + + protected function inlineSpecialCharacter($Excerpt) { + if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false + and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches) + ) { + return array( + 'element' => array('rawHtml' => '&'.$matches[1].';'), + 'extent' => strlen($matches[0]), + ); + } + + return; + } + + protected function inlineStrikethrough($Excerpt) { + if (!isset($Excerpt['text'][1])) { + return; + } + + if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) { + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'del', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $matches[1], + 'destination' => 'elements', + ) + ), + ); + } + } + + protected function inlineUrl($Excerpt) { + if ($this->urlsLinked !== true or !isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') { + return; + } + + if (strpos($Excerpt['context'], 'http') !== false + and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE) + ) { + $url = $matches[0][0]; + + $Inline = array( + 'extent' => strlen($matches[0][0]), + 'position' => $matches[0][1], + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + + return $Inline; + } + } + + protected function inlineUrlTag($Excerpt) { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) { + $url = $matches[1]; + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + # ~ + + protected function unmarkedText($text) { + $Inline = $this->inlineText($text); + return $this->element($Inline['element']); + } + + # + # Handlers + # + + protected function handle(array $Element) { + if (isset($Element['handler'])) { + if (!isset($Element['nonNestables'])) { + $Element['nonNestables'] = array(); + } + + 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; + } + + protected function handleElementRecursive(array $Element) { + return $this->elementApplyRecursive(array($this, 'handle'), $Element); + } + + protected function handleElementsRecursive(array $Elements) { + return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); + } + + protected function elementApplyRecursive($closure, array $Element) { + $Element = call_user_func($closure, $Element); + + if (isset($Element['elements'])) { + $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); + } elseif (isset($Element['element'])) { + $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); + } + + return $Element; + } + + protected function elementApplyRecursiveDepthFirst($closure, array $Element) { + if (isset($Element['elements'])) { + $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); + } elseif (isset($Element['element'])) { + $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); + } + + $Element = call_user_func($closure, $Element); + + return $Element; + } + + protected function elementsApplyRecursive($closure, array $Elements) { + foreach ($Elements as &$Element) { + $Element = $this->elementApplyRecursive($closure, $Element); + } + + return $Elements; + } + + protected function elementsApplyRecursiveDepthFirst($closure, array $Elements) { + foreach ($Elements as &$Element) { + $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); + } + + return $Elements; + } + + protected function element(array $Element) { + if ($this->safeMode) { + $Element = $this->sanitiseElement($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=\"".self::escape($value).'"'; + } + } + } + + $permitRawHtml = false; + + if (isset($Element['text'])) { + $text = $Element['text']; + } + // very strongly consider an alternative if you're writing an + // extension + elseif (isset($Element['rawHtml'])) { + $text = $Element['rawHtml']; + + $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; + $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; + } + + $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']); + } else { + if (!$permitRawHtml) { + $markup .= self::escape($text, true); + } else { + $markup .= $text; + } + } + + $markup .= $hasName ? '' : ''; + } elseif ($hasName) { + $markup .= ' />'; + } + + return $markup; + } + + protected function elements(array $Elements) { + $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 ? $autoBreak : $autoBreakNext; + + $markup .= ($autoBreak ? "\n" : '').$this->element($Element); + $autoBreak = $autoBreakNext; + } + + $markup .= $autoBreak ? "\n" : ''; + + return $markup; + } + + # ~ + + protected function li($lines) { + $Elements = $this->linesElements($lines); + + if (!in_array('', $lines) + and isset($Elements[0]) and isset($Elements[0]['name']) + and $Elements[0]['name'] === 'p' + ) { + unset($Elements[0]['name']); + } + + return $Elements; + } + + # + # AST Convenience + # + + /** + * Replace occurrences $regexp with $Elements in $text. Return an array of + * elements representing the replacement. + */ + protected static function pregReplaceElements($regexp, $Elements, $text) { + $newElements = array(); + + while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) { + $offset = $matches[0][1]; + $before = substr($text, 0, $offset); + $after = substr($text, $offset + strlen($matches[0][0])); + + $newElements[] = array('text' => $before); + + foreach ($Elements as $Element) { + $newElements[] = $Element; + } + + $text = $after; + } + + $newElements[] = array('text' => $text); + + return $newElements; + } + + # + # Deprecated Methods + # + + function parse($text) { + $markup = $this->text($text); + + return $markup; + } + + protected function sanitiseElement(array $Element) { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + '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 => $val) { + # filter out badly parsed attribute + if (!preg_match($goodAttribute, $att)) { + unset($Element['attributes'][$att]); + } # dump onevent attribute + elseif (self::striAtStart($att, 'on')) { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) { + foreach ($this->safeLinksWhitelist as $scheme) { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + + # + # Static Methods + # + + protected static function escape($text, $allowQuotes = false) { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) { + $len = strlen($needle); + + if ($len > strlen($string)) { + return false; + } else { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + + static function instance($name = 'default') { + if (isset(self::$instances[$name])) { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~' + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + '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', + ); +} \ No newline at end of file diff --git a/src/thirdparty/ParsedownExtended.php b/src/thirdparty/ParsedownExtended.php new file mode 100644 index 0000000..2eafcec --- /dev/null +++ b/src/thirdparty/ParsedownExtended.php @@ -0,0 +1,3704 @@ +checkVersion('PHP', PHP_VERSION, self::MIN_PHP_VERSION); + + // Check if the installed Parsedown version meets the minimum requirement + $this->checkVersion('Parsedown', Parsedown::version, self::VERSION_PARSEDOWN_REQUIRED); + + if (class_exists('ParsedownExtra')) { + // Ensure ParsedownExtra meets the version requirement + $this->checkVersion('ParsedownExtra', \ParsedownExtra::version, self::VERSION_PARSEDOWN_EXTRA_REQUIRED); + parent::__construct(); + } + + $this->setLegacyMode(); + + // Initialize settings with the provided schema + $this->configSchema = $this->defineConfigSchema(); + $this->config = $this->initializeConfig($this->configSchema); + + // Add support for inline types (e.g., special formatting) + $this->addInlineType('=', 'Marking'); + $this->addInlineType('+', 'Insertions'); + $this->addInlineType('[', 'Keystrokes'); + $this->addInlineType(['\\', '$'], 'MathNotation'); + $this->addInlineType('^', 'Superscript'); + $this->addInlineType('~', 'Subscript'); + $this->addInlineType(':', 'Emojis'); + $this->addInlineType(['<', '>', '-', '.', "'", '"', '`'], 'Smartypants'); + $this->addInlineType(['(', '.', '+', '!', '?'], 'Typographer'); + + // Add support for block types (e.g., blocks of content) + $this->addBlockType(['\\', '$'], 'MathNotation'); + $this->addBlockType('>', 'Alert'); + + // Reorganize 'SpecialCharacter' to ensure it is processed last in InlineTypes and BlockTypes + foreach ($this->InlineTypes as &$list) { + if (($key = array_search('SpecialCharacter', $list)) !== false) { + unset($list[$key]); + $list[] = 'SpecialCharacter'; // Append 'SpecialCharacter' at the end + } + } + + foreach ($this->BlockTypes as &$list) { + if (($key = array_search('SpecialCharacter', $list)) !== false) { + unset($list[$key]); + $list[] = 'SpecialCharacter'; // Append 'SpecialCharacter' at the end + } + } + } + + /** + * Check version compatibility for a specific component. + * + * Verifies if the current version of a component (e.g., PHP or Parsedown) meets the required version. + * Throws an exception if the version is not sufficient. + * + * @param string $component The name of the component being checked (e.g., 'PHP', 'Parsedown') + * @param string $currentVersion The current version of the component installed + * @param string $requiredVersion The minimum required version of the component + * + * @throws \Exception If the current version is lower than the required version + * @since 1.3.0 + * + */ + private function checkVersion(string $component, string $currentVersion, string $requiredVersion): void { + // Compare the current version with the required version + if (version_compare($currentVersion, $requiredVersion) < 0) { + // Prepare an error message indicating version incompatibility + $msg_error = 'Version Error.'.PHP_EOL; + $msg_error .= " ParsedownExtended requires a later version of $component.".PHP_EOL; + $msg_error .= " - Current version : $currentVersion".PHP_EOL; + $msg_error .= " - Required version: $requiredVersion and later".PHP_EOL; + + // Throw an exception with the version error message + throw new \Exception($msg_error); + } + } + + + /** + * Method setLegacyMode + * + * Sets the legacy mode based on the version of Parsedown. + * + * @return void + * @since 1.3.0 + * + */ + private function setLegacyMode(): void { + $parsedownVersion = preg_replace('/-.*$/', '', Parsedown::version); + + // Enable legacy mode if Parsedown version is between 1.7.4 and below 1.8.0 + if (version_compare($parsedownVersion, '1.8.0') < 0 && version_compare($parsedownVersion, '1.7.4') >= 0) { + $this->legacyMode = true; + } + } + + // Inline types + // ------------------------------------------------------------------------- + + /** + * Processes inline code elements. + * + * Handles inline code if it is enabled in the configuration settings. + * + * @param array $Excerpt The portion of text being parsed + * @return mixed|null The parsed element or null if not processed + * @since 0.1.0 + * + */ + protected function inlineCode($Excerpt) { + if ($this->config()->get('code') && $this->config()->get('code.inline')) { + return parent::inlineCode($Excerpt); + } + } + + /** + * Processes inline images. + * + * Handles inline images if the feature is enabled in the configuration. + * + * @param array $Excerpt The portion of text being parsed + * @return mixed|null The parsed image element or null if not processed + * @since 0.1.0 + * + */ + protected function inlineImage($Excerpt) { + if ($this->config()->get('images')) { + return parent::inlineImage($Excerpt); + } + } + + /** + * Processes inline HTML markup. + * + * Parses inline HTML if raw HTML is allowed in the configuration. + * + * @param array $Excerpt The portion of text being parsed + * @return mixed|null The parsed HTML markup or null if not allowed + * @since 0.1.0 + * + */ + protected function inlineMarkup($Excerpt) { + if ($this->config()->get('allow_raw_html')) { + return parent::inlineMarkup($Excerpt); + } + } + + /** + * Processes inline strikethrough elements. + * + * Handles inline strikethrough text if the emphasis is enabled in the configuration. + * + * @param array $Excerpt The portion of text being parsed + * @return mixed|null The parsed strikethrough or null if not processed + * @since 0.1.0 + * + */ + protected function inlineStrikethrough($Excerpt) { + if ($this->config()->get('emphasis.strikethroughs') && $this->config()->get('emphasis')) { + return parent::inlineStrikethrough($Excerpt); + } + } + + /** + * Processes inline links. + * + * Extends link processing to handle custom link behaviors. + * + * @param array $Excerpt The portion of text being parsed + * @return array|null The processed link element or null if not processed + * @since 0.1.0 + * + */ + protected function inlineLink($Excerpt) { + return $this->processLinkElement(parent::inlineLink($Excerpt)); + } + + /** + * Processes inline URLs. + * + * Extends the URL processing to include additional custom behavior, such as modifying the parsed URL element. + * + * @param array $Excerpt The portion of text being parsed + * @return array|null The processed URL element or null if not processed + * @since 0.1.0 + * + */ + protected function inlineUrl($Excerpt) { + return $this->processLinkElement(parent::inlineUrl($Excerpt)); + } + + /** + * Processes inline URL tags. + * + * Handles parsing of inline URL tags, adding any custom behavior if needed. + * + * @param array $Excerpt The portion of text being parsed + * @return array|null The processed URL tag or null if not processed + * @since 0.1.0 + * + */ + protected function inlineUrlTag($Excerpt) { + return $this->processLinkElement(parent::inlineUrlTag($Excerpt)); + } + + + /** + * Processes inline email tags. + * + * Handles email links if the feature is enabled in the configuration. + * + * @param array $Excerpt The portion of text being parsed + * @return mixed|null The parsed email tag or null if links are disabled + * @since 0.1.0 + * + */ + protected function inlineEmailTag($Excerpt) { + if (!$this->config()->get('links') || !$this->config()->get('links.email_links')) { + return null; + } + + $Excerpt = parent::inlineEmailTag($Excerpt); + + if (isset($Excerpt['element']['attributes']['href'])) { + $Excerpt['element']['attributes']['target'] = '_blank'; + } + + return $Excerpt; + } + + + /** + * Processes link elements to add behavior control attributes. + * + * Extends parsed Markdown link elements to include attributes such as `nofollow`, `noopener`, and `noreferrer` + * based on the configuration settings, particularly for external links. This helps control search engine indexing, + * external page behavior, and referrer privacy. + * + * @param array $Excerpt The portion of text representing the link element. + * @return array|null Modified link element with added attributes or null if the link is disallowed. + * @since 1.3.0 + * + */ + protected function processLinkElement($Excerpt) { + if (!$this->config()->get('links') || !$Excerpt || !isset($Excerpt['element']['attributes']['href'])) { + return null; + } + + if (isset($Excerpt['element']['attributes']['href'])) { + // Get the href attribute + $href = $Excerpt['element']['attributes']['href']; + + // Check if link is an external link + $isExternal = $this->isExternalLink($href); + + if ($isExternal === true) { + // Check if external links are disabled + if (!$this->config()->get('links.external_links')) { + return null; + } + + $rel = []; + + // Add nofollow attribute if specified in the configuration + if ($this->config()->get('links.external_links.nofollow')) { + $rel[] = 'nofollow'; + } + + // Add noopener attribute if specified in the configuration + if ($this->config()->get('links.external_links.noopener')) { + $rel[] = 'noopener'; + } + + // Add noreferrer attribute if specified in the configuration + if ($this->config()->get('links.external_links.noreferrer')) { + $rel[] = 'noreferrer'; + } + + // Add target attribute with '_blank' value + if ($this->config()->get('links.external_links.open_in_new_window')) { + $Excerpt['element']['attributes']['target'] = '_blank'; + } + + // Add rel attribute with values from the $rel array + if (!empty($rel)) { + $Excerpt['element']['attributes']['rel'] = implode(' ', $rel); + } + } + } + + return $Excerpt; + } + + /** + * Determines if a given link is an external link. + * + * Checks if the link is either protocol-relative (starts with `//`) or absolute (`http://` or `https://`) + * and if the host differs from the current server's host. It also checks against a list of internal hosts to identify external links. + * + * @param string $href The URL to check. + * @return bool Returns true if the link is external, false otherwise. + * @since 1.3.0 + * + */ + private function isExternalLink($href) { + // Check if the URL is protocol-relative (e.g., starts with `//`) + $isProtocolRelative = preg_match('/^\/\//', $href); + + // Check if the URL is an absolute URL (starts with http:// or https://) + $isAbsolute = preg_match('/^https?:\/\//i', $href); + + if ($isProtocolRelative || $isAbsolute) { + // Extract the host part of the URL + $host = parse_url($href, PHP_URL_HOST); + + // Check if the domain matches the current domain + if ($host && $host !== $_SERVER['HTTP_HOST']) { + + // Remove 'www.' from the host to get the base domain name + $domain = preg_replace('/^www\\./', '', $host); + + // Get the list of internal hosts from the configuration + $internalHosts = $this->config()->get('links.external_links.internal_hosts'); + + // Return false if the link is considered internal based on the configuration + if (in_array($domain, $internalHosts)) { + return false; + } + return true; // If the link is not internal, it is external + } + } + + return false; + } + + /** + * Processes inline emphasis elements. + * + * Handles inline emphasis (like bold or italics) if enabled in the configuration. + * + * @param array $Excerpt The portion of text being parsed + * @return array|null The parsed emphasis or null if not processed + * @since 0.1.0 + * + */ + protected function inlineEmphasis($Excerpt) { + if (!$this->config()->get('emphasis') || !isset($Excerpt['text'][1])) { + return null; // If emphasis is disabled or the excerpt is too short, return null + } + + $marker = $Excerpt['text'][0]; // Extract the marker character ('*', '_', etc.) + + // Check if the text matches bold emphasis using the marker + if ($this->config()->get('emphasis.bold') && preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) { + $emphasis = 'strong'; // Use 'strong' for bold text + } // Check if the text matches italic emphasis using the marker + elseif ($this->config()->get('emphasis.italic') && preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) { + $emphasis = 'em'; // Use 'em' for italic text + } else { + return null; // No valid emphasis match found + } + + // Return the parsed emphasis element + return [ + 'extent' => strlen($matches[0]), // Length of the matched emphasis text + 'element' => [ + 'name' => $emphasis, // 'strong' for bold or 'em' for italics + 'handler' => 'line', // Handler for further inline processing + 'text' => $matches[1], // The emphasized content + ], + ]; + } + + /** + * Processes inline marking elements. + * + * Handles inline marking by using double equal signs (`==text==`). This will convert the marked text + * into an HTML `` tag if the feature is enabled in the configuration. + * + * @param array $Excerpt The portion of text being parsed to identify marking. + * @return array|null The parsed marking element or null if marking is disabled or not applicable. + * @since 1.2.0 + * + */ + protected function inlineMarking(array $Excerpt): ?array { + // Check if marking is enabled in the configuration settings + if (!$this->config()->get('emphasis.mark') || !$this->config()->get('emphasis')) { + return null; // Return null if marking or emphasis is disabled + } + + // Match the double equal signs for marking (`==text==`) using regex + if (preg_match('/^==((?:\\\\\=|[^=]|=[^=]*=)+?)==(?!=)/s', $Excerpt['text'], $matches)) { + // Return the parsed marking element + return [ + 'extent' => strlen($matches[0]), // The length of the matched marking text + 'element' => [ + 'name' => 'mark', // The HTML tag used for marking + 'text' => $matches[1], // The content inside the marking + ], + ]; + } + + return null; // If no match is found, return null + } + + /** + * Processes inline insertion elements. + * + * Handles inline insertions denoted by double plus signs (`++text++`). If enabled in the configuration, + * this will convert the marked text into an HTML `` tag, which is commonly used to indicate additions. + * + * @param array $Excerpt The portion of text being parsed to identify insertions. + * @return array|null The parsed insertion element or null if insertions are disabled or not applicable. + * @since 1.2.0 + * + */ + protected function inlineInsertions(array $Excerpt): ?array { + // Check if insertions are enabled in the configuration settings + if (!$this->config()->get('emphasis.insertions') || !$this->config()->get('emphasis')) { + return null; // Return null if insertions or general emphasis is disabled + } + + // Match the double plus signs for insertions (`++text++`) using regex + if (preg_match('/^\+\+((?:\\\\\+|[^\+]|\+[^\+]*\+)+?)\+\+(?!\+)/s', $Excerpt['text'], $matches)) { + // Return the parsed insertion element + return [ + 'extent' => strlen($matches[0]), // The length of the matched insertion text + 'element' => [ + 'name' => 'ins', // The HTML tag used for insertions + 'text' => $matches[1], // The content inside the insertion + ], + ]; + } + + return null; // If no match is found, return null + } + + /** + * Processes inline keystroke elements. + * + * Handles inline keystrokes denoted by double square brackets (`[[text]]`). If enabled in the configuration, + * this will convert the enclosed text into an HTML `` tag, which is typically used to represent user input or keystrokes. + * + * @param array $Excerpt The portion of text being parsed to identify keystrokes. + * @return array|null The parsed keystroke element or null if keystrokes are disabled or not applicable. + * @since 1.0.0 + * + */ + protected function inlineKeystrokes(array $Excerpt): ?array { + // Check if keystrokes are enabled in the configuration settings + if (!$this->config()->get('emphasis.keystrokes') || !$this->config()->get('emphasis')) { + return null; // Return null if keystrokes or general emphasis is disabled + } + + // Match the double square brackets for keystrokes (`[[text]]`) using regex + if (preg_match('/^(? strlen($matches[0]), // The length of the matched keystroke text + 'element' => [ + 'name' => 'kbd', // The HTML tag used for keystrokes + 'text' => $matches[1], // The content inside the keystroke brackets + ], + ]; + } + + return null; // If no match is found, return null + } + + + /** + * Processes inline superscript elements. + * + * Handles inline superscript denoted by a caret symbol (`^text^`). If enabled in the configuration, + * this will convert the marked text into an HTML `` tag, which is typically used for superscripts in text. + * + * @param array $Excerpt The portion of text being parsed to identify superscript. + * @return array|null The parsed superscript element or null if superscript is disabled or not applicable. + * @since 1.0.0 + * + */ + protected function inlineSuperscript(array $Excerpt): ?array { + // Check if superscript is enabled in the configuration settings + if (!$this->config()->get('emphasis.superscript') || !$this->config()->get('emphasis')) { + return null; // Return null if superscript or general emphasis is disabled + } + + // Match the caret symbols for superscript (`^text^`) using regex + if (preg_match('/^\^((?:\\\\\\^|[^\^]|\^[^\^]+?\^\^)+?)\^(?!\^)/s', $Excerpt['text'], $matches)) { + // Return the parsed superscript element + return [ + 'extent' => strlen($matches[0]), // The length of the matched superscript text + 'element' => [ + 'name' => 'sup', // The HTML tag used for superscript + 'text' => $matches[1], // The content inside the superscript markers + ], + ]; + } + + return null; // If no match is found, return null + } + + + /** + * Processes inline subscript elements. + * + * Handles inline subscript denoted by a tilde (`~text~`). If enabled in the configuration, + * this will convert the marked text into an HTML `` tag, which is typically used for subscripts in text. + * + * @param array $Excerpt The portion of text being parsed to identify subscript. + * @return array|null The parsed subscript element or null if subscript is disabled or not applicable. + * @since 1.0.0 + * + */ + protected function inlineSubscript(array $Excerpt): ?array { + // Check if subscript is enabled in the configuration settings + if (!$this->config()->get('emphasis.subscript') || !$this->config()->get('emphasis')) { + return null; // Return null if subscript or general emphasis is disabled + } + + // Match the tilde symbols for subscript (`~text~`) using regex + if (preg_match('/^~((?:\\\\~|[^~]|~~[^~]*~~)+?)~(?!~)/s', $Excerpt['text'], $matches)) { + // Return the parsed subscript element + return [ + 'extent' => strlen($matches[0]), // The length of the matched subscript text + 'element' => [ + 'name' => 'sub', // The HTML tag used for subscript + 'text' => $matches[1], // The content inside the subscript markers + ], + ]; + } + + return null; // If no match is found, return null + } + + + /** + * Processes inline math notation elements. + * + * Handles inline math notation using specific delimiters (e.g., `$...$`, `\\(...\\)`). If enabled in the configuration, + * this function matches math notation within the specified delimiters and processes it accordingly. + * + * @param array $Excerpt The portion of text being parsed to identify math notation. + * @return array|null The parsed math notation element or null if math parsing is disabled or not applicable. + * @since 1.1.2 + * + */ + protected function inlineMathNotation($Excerpt) { + // Check if parsing of math notation is enabled in the configuration settings + if (!$this->config()->get('math') || !$this->config()->get('math.inline')) { + return null; // Return null if math or inline math is disabled + } + + // Check if the excerpt has enough characters to proceed + if (!isset($Excerpt['text'][1])) { + return null; // Return null if there is insufficient text for math notation + } + + // Check if there is whitespace before the excerpt (ensures math is not in the middle of a word) + if ($Excerpt['before'] !== '' && preg_match('/\s/', $Excerpt['before']) === 0) { + return null; // Return null if the math notation is not preceded by whitespace + } + + // Iterate through the inline math delimiters (e.g., `$...$`, `\\(...\\)`) + foreach ($this->config()->get('math.inline.delimiters') as $config) { + $leftMarker = preg_quote($config['left'], '/'); // Escape the left delimiter for use in regex + $rightMarker = preg_quote($config['right'], '/'); // Escape the right delimiter for use in regex + + // Create the regex pattern for matching math notation + if ($config['left'][0] === '\\' || strlen($config['left']) > 1) { + $regex = '/^(? strlen($matches[0]), // The length of the matched math notation + 'element' => [ + 'text' => $matches[0], // The matched math content + ], + ]; + } + } + + return null; // If no match is found, return null + } + + + /** + * Processes inline escape sequences. + * + * Handles escape sequences to allow special characters to be rendered as literals instead of being interpreted. + * Specifically, if a character is preceded by a backslash, it is treated as an escaped character. + * Additionally, it ensures that math delimiters are not mistakenly escaped. + * + * @param array $Excerpt The portion of text being parsed to identify escape sequences. + * @return array|null The parsed escape sequence element or null if no valid escape sequence is found. + * @since 0.1.0 + * + */ + protected function inlineEscapeSequence($Excerpt) { + // If math is enabled, check for any inline math delimiters that might need special handling + if ($this->config()->get('math')) { + foreach ($this->config()->get('math.inline.delimiters') as $config) { + $leftMarker = preg_quote($config['left'], '/'); // Escape the left delimiter for use in regex + $rightMarker = preg_quote($config['right'], '/'); // Escape the right delimiter for use in regex + + // Create the regex pattern for matching math notation + if ($config['left'][0] === '\\' || strlen($config['left']) > 1) { + $regex = '/^(?specialCharacters)) { + // Return the escaped character + return [ + 'markup' => $Excerpt['text'][1], // The character to be escaped + 'extent' => 2, // The length of the escape sequence (backslash + character) + ]; + } + + // If no valid escape sequence is found, return null + return null; + } + + + /** + * Processes inline typographic substitutions. + * + * This function handles typographic improvements, such as replacing plain text with their typographic equivalents. + * It processes symbols like (c) to ©, (r) to ®, and smart ellipses based on the user's configuration. + * This is particularly useful for enhancing readability by applying typographer rules. + * + * @param array $Excerpt The portion of text being parsed for typographic substitutions. + * @return array|null The parsed typographic substitutions or null if the typographer feature is disabled. + * @since 1.0.1 + * + */ + protected function inlineTypographer(array $Excerpt): ?array { + // Check if typographer is enabled in the configuration settings + if (!$this->config()->get('typographer')) { + return null; // Return null if the typographer is disabled + } + + // Check if smartypants and smart ellipses settings are enabled + $ellipses = $this->config()->get('smartypants') && $this->config()->get('smartypants.smart_ellipses') + ? html_entity_decode($this->config()->get('smartypants.substitutions.ellipses')) + : '...'; // Use smart ellipses if enabled, otherwise use '...' + + // Define substitutions for various typographic symbols + $substitutions = [ + '/\(c\)/i' => html_entity_decode('©'), // Replace (c) with © symbol + '/\(r\)/i' => html_entity_decode('®'), // Replace (r) with ® symbol + '/\(tm\)/i' => html_entity_decode('™'), // Replace (tm) with ™ symbol + '/\(p\)/i' => html_entity_decode('¶'), // Replace (p) with ¶ symbol (paragraph) + '/\+-/i' => html_entity_decode('±'), // Replace +- with ± symbol + '/\!\.{3,}/i' => '!..', // Replace more than three exclamation points with '!..' + '/\?\.{3,}/i' => '?..', // Replace more than three question marks with '?..' + '/\.{2,}/i' => $ellipses, // Replace ellipses with either smart ellipses or '...' + ]; + + // Apply substitutions using regular expressions + $result = preg_replace(array_keys($substitutions), array_values($substitutions), $Excerpt['text'], -1, $count); + + // If substitutions were made, return the modified text + if ($count > 0) { + return [ + 'extent' => strlen($Excerpt['text']), // The length of the original excerpt text + 'element' => [ + 'text' => $result, // The modified text after applying typographic substitutions + ], + ]; + } + + return null; // If no substitutions were made, return null + } + + + /** + * Processes inline Smartypants substitutions. + * + * This function handles typographic improvements to the text, such as converting straight quotes to curly quotes, + * converting double angle quotes, converting dashes into em or en dashes, and ellipses into the proper character. + * These changes enhance readability and align text formatting with common typographic standards. + * + * @param array $Excerpt The portion of text being parsed for Smartypants substitutions. + * @return array|null The parsed Smartypants substitution or null if Smartypants is disabled. + * @since 1.0.0 + * + */ + protected function inlineSmartypants($Excerpt) { + // Check if Smartypants is enabled in the configuration settings + if (!$this->config()->get('smartypants')) { + return null; // Return null if Smartypants is disabled + } + + // Substitutions: Load the characters to use for the specific Smartypants transformations + $substitutions = [ + 'left_double_quote' => html_entity_decode($this->config()->get('smartypants.substitutions.left_double_quote')), + 'right_double_quote' => html_entity_decode($this->config()->get('smartypants.substitutions.right_double_quote')), + 'left_single_quote' => html_entity_decode($this->config()->get('smartypants.substitutions.left_single_quote')), + 'right_single_quote' => html_entity_decode($this->config()->get('smartypants.substitutions.right_single_quote')), + 'left_angle_quote' => html_entity_decode($this->config()->get('smartypants.substitutions.left_angle_quote')), + 'right_angle_quote' => html_entity_decode($this->config()->get('smartypants.substitutions.right_angle_quote')), + 'mdash' => html_entity_decode($this->config()->get('smartypants.substitutions.mdash')), + 'ndash' => html_entity_decode($this->config()->get('smartypants.substitutions.ndash')), + 'ellipses' => html_entity_decode($this->config()->get('smartypants.substitutions.ellipses')), + ]; + + // Define patterns for various Smartypants substitutions + $patterns = [ + 'smart_backticks' => [ + 'pattern' => '/^(``)(?!\s)([^"\'`]{1,})(\'\')/i', + 'callback' => function ($matches) use ($substitutions, $Excerpt) { + if (strlen(trim($Excerpt['before'])) > 0) { + return null; // Skip if the backticks do not start at the beginning + } + + // Return transformed text with left and right double quotes + return [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'text' => $substitutions['left_double_quote'].$matches[2].$substitutions['right_double_quote'], + ], + ]; + }, + ], + 'smart_quotes' => [ + 'pattern' => '/^(")(?!\s)([^"]+)(")|^(? function ($matches) use ($substitutions, $Excerpt) { + if (strlen(trim($Excerpt['before'])) > 0) { + return null; // Skip if quotes are in the middle of a word + } + + // Check if the match is for single or double quotes and return transformed text + if ("'" === $matches[1]) { + return [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'text' => $substitutions['left_single_quote'].$matches[2].$substitutions['right_single_quote'], + ], + ]; + } + + if ('"' === $matches[1]) { + return [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'text' => $substitutions['left_double_quote'].$matches[2].$substitutions['right_double_quote'], + ], + ]; + } + }, + ], + 'smart_angled_quotes' => [ + 'pattern' => '/^(<{2})(?!\s)([^<>]+)(>{2})/i', + 'callback' => function ($matches) use ($substitutions, $Excerpt) { + if (strlen(trim($Excerpt['before'])) > 0) { + return null; // Skip if angled quotes do not start at the beginning + } + + // Return transformed text with left and right angle quotes + return [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'text' => $substitutions['left_angle_quote'].$matches[2].$substitutions['right_angle_quote'], + ], + ]; + }, + ], + 'smart_dashes' => [ + 'pattern' => '/^(-{2,3})/i', + 'callback' => function ($matches) use ($substitutions) { + // Replace double dashes with ndash or triple dashes with mdash + if ('---' === $matches[1]) { + return [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'text' => $substitutions['mdash'], + ], + ]; + } + + if ('--' === $matches[1]) { + return [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'text' => $substitutions['ndash'], + ], + ]; + } + }, + ], + 'smart_ellipses' => [ + 'pattern' => '/^(? function ($matches) use ($substitutions) { + // Replace three dots with an ellipsis + return [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'text' => $substitutions['ellipses'], + ], + ]; + }, + ], + ]; + + // Iterate over each pattern and apply the corresponding callback if a match is found + foreach ($patterns as $key => $value) { + if ($this->config()->get('smartypants.'.$key) && preg_match($value['pattern'], $Excerpt['text'], $matches)) { + $matches = array_values(array_filter($matches)); // Filter out empty matches + return $value['callback']($matches); // Return the transformed text using the callback + } + } + + // If no substitutions were made, return null + return null; + } + + + /** + * Processes inline emoji replacements. + * + * This function handles the conversion of text-based emoji shortcuts (e.g., `:smile:`) to their corresponding emoji characters (e.g., 😄). + * Emojis are replaced based on a predefined emoji map if the emoji feature is enabled in the configuration. + * + * @param array $Excerpt The portion of text being parsed to identify emoji codes. + * @return array|null The parsed emoji element or null if emojis are disabled or no match is found. + * @since 1.0.0 + * + */ + protected function inlineEmojis(array $Excerpt): ?array { + // Check if emoji processing is enabled in the configuration settings + if (!$this->config()->get('emojis')) { + return null; // Return null if emoji replacement is disabled + } + + // Define a mapping of emoji codes to their corresponding Unicode characters + $emojiMap = [ + "grinning_face" => "😀", "grinning_face_with_big_eyes" => "😃", "grinning_face_with_smiling_eyes" => "😄", "beaming_face_with_smiling_eyes" => "😁", + "grinning_squinting_face" => "😆", "grinning_face_with_sweat" => "😅", "rolling_on_the_floor_laughing" => "🤣", "face_with_tears_of_joy" => "😂", + "slightly_smiling_face" => "🙂", "upside_down_face" => "🙃", "melting_face" => "🫠", "winking_face" => "😉", + "smiling_face_with_smiling_eyes" => "😊", "smiling_face_with_halo" => "😇", "smiling_face_with_hearts" => "🥰", "smiling_face_with_heart_eyes" => "😍", + "star_struck" => "🤩", "face_blowing_a_kiss" => "😘", "kissing_face" => "😗", "smiling_face" => "☺️", + "kissing_face_with_closed_eyes" => "😚", "kissing_face_with_smiling_eyes" => "😙", "smiling_face_with_tear" => "🥲", "face_savoring_food" => "😋", + "face_with_tongue" => "😛", "winking_face_with_tongue" => "😜", "zany_face" => "🤪", "squinting_face_with_tongue" => "😝", + "money_mouth_face" => "🤑", "smiling_face_with_open_hands" => "🤗", "face_with_hand_over_mouth" => "🤭", "face_with_open_eyes_and_hand_over_mouth" => "🫢", + "face_with_peeking_eye" => "🫣", "shushing_face" => "🤫", "thinking_face" => "🤔", "saluting_face" => "🫡", + "zipper_mouth_face" => "🤐", "face_with_raised_eyebrow" => "🤨", "neutral_face" => "😐", "expressionless_face" => "😑", + "face_without_mouth" => "😶", "dotted_line_face" => "🫥", "face_in_clouds" => "😶‍🌫️", "smirking_face" => "😏", + "unamused_face" => "😒", "face_with_rolling_eyes" => "🙄", "grimacing_face" => "😬", "face_exhaling" => "😮‍💨", + "lying_face" => "🤥", "shaking_face" => "🫨", "head_shaking_horizontally" => "🙂‍↔️", "head_shaking_vertically" => "🙂‍↕️", + "relieved_face" => "😌", "pensive_face" => "😔", "sleepy_face" => "😪", "drooling_face" => "🤤", + "sleeping_face" => "😴", "face_with_bags_under_eyes" => "🫩", "face_with_medical_mask" => "😷", "face_with_thermometer" => "🤒", + "face_with_head_bandage" => "🤕", "nauseated_face" => "🤢", "face_vomiting" => "🤮", "sneezing_face" => "🤧", + "hot_face" => "🥵", "cold_face" => "🥶", "woozy_face" => "🥴", "face_with_crossed_out_eyes" => "😵", + "face_with_spiral_eyes" => "😵‍💫", "exploding_head" => "🤯", "cowboy_hat_face" => "🤠", "partying_face" => "🥳", + "disguised_face" => "🥸", "smiling_face_with_sunglasses" => "😎", "nerd_face" => "🤓", "face_with_monocle" => "🧐", + "confused_face" => "😕", "face_with_diagonal_mouth" => "🫤", "worried_face" => "😟", "slightly_frowning_face" => "🙁", + "frowning_face" => "☹️", "face_with_open_mouth" => "😮", "hushed_face" => "😯", "astonished_face" => "😲", + "flushed_face" => "😳", "pleading_face" => "🥺", "face_holding_back_tears" => "🥹", "frowning_face_with_open_mouth" => "😦", + "anguished_face" => "😧", "fearful_face" => "😨", "anxious_face_with_sweat" => "😰", "sad_but_relieved_face" => "😥", + "crying_face" => "😢", "loudly_crying_face" => "😭", "face_screaming_in_fear" => "😱", "confounded_face" => "😖", + "persevering_face" => "😣", "disappointed_face" => "😞", "downcast_face_with_sweat" => "😓", "weary_face" => "😩", + "tired_face" => "😫", "yawning_face" => "🥱", "face_with_steam_from_nose" => "😤", "enraged_face" => "😡", + "angry_face" => "😠", "face_with_symbols_on_mouth" => "🤬", "smiling_face_with_horns" => "😈", "angry_face_with_horns" => "👿", + "skull" => "💀", "skull_and_crossbones" => "☠️", "pile_of_poo" => "💩", "clown_face" => "🤡", + "ogre" => "👹", "goblin" => "👺", "ghost" => "👻", "alien" => "👽", + "alien_monster" => "👾", "robot" => "🤖", "grinning_cat" => "😺", "grinning_cat_with_smiling_eyes" => "😸", + "cat_with_tears_of_joy" => "😹", "smiling_cat_with_heart_eyes" => "😻", "cat_with_wry_smile" => "😼", "kissing_cat" => "😽", + "weary_cat" => "🙀", "crying_cat" => "😿", "pouting_cat" => "😾", "see_no_evil_monkey" => "🙈", + "hear_no_evil_monkey" => "🙉", "speak_no_evil_monkey" => "🙊", "love_letter" => "💌", "heart_with_arrow" => "💘", + "heart_with_ribbon" => "💝", "sparkling_heart" => "💖", "growing_heart" => "💗", "beating_heart" => "💓", + "revolving_hearts" => "💞", "two_hearts" => "💕", "heart_decoration" => "💟", "heart_exclamation" => "❣️", + "broken_heart" => "💔", "heart_on_fire" => "❤️‍🔥", "mending_heart" => "❤️‍🩹", "red_heart" => "❤️", + "pink_heart" => "🩷", "orange_heart" => "🧡", "yellow_heart" => "💛", "green_heart" => "💚", + "blue_heart" => "💙", "light_blue_heart" => "🩵", "purple_heart" => "💜", "brown_heart" => "🤎", + "black_heart" => "🖤", "grey_heart" => "🩶", "white_heart" => "🤍", "kiss_mark" => "💋", + "hundred_points" => "💯", "anger_symbol" => "💢", "collision" => "💥", "dizzy" => "💫", + "sweat_droplets" => "💦", "dashing_away" => "💨", "hole" => "🕳️", "speech_balloon" => "💬", + "eye_in_speech_bubble" => "👁️‍🗨️", "left_speech_bubble" => "🗨️", "right_anger_bubble" => "🗯️", "thought_balloon" => "💭", + "zzz" => "💤", "waving_hand" => "👋", "raised_back_of_hand" => "🤚", "hand_with_fingers_splayed" => "🖐️", + "raised_hand" => "✋", "vulcan_salute" => "🖖", "rightwards_hand" => "🫱", "leftwards_hand" => "🫲", + "palm_down_hand" => "🫳", "palm_up_hand" => "🫴", "leftwards_pushing_hand" => "🫷", "rightwards_pushing_hand" => "🫸", + "ok_hand" => "👌", "pinched_fingers" => "🤌", "pinching_hand" => "🤏", "victory_hand" => "✌️", + "crossed_fingers" => "🤞", "hand_with_index_finger_and_thumb_crossed" => "🫰", "love_you_gesture" => "🤟", "sign_of_the_horns" => "🤘", + "call_me_hand" => "🤙", "backhand_index_pointing_left" => "👈", "backhand_index_pointing_right" => "👉", "backhand_index_pointing_up" => "👆", + "middle_finger" => "🖕", "backhand_index_pointing_down" => "👇", "index_pointing_up" => "☝️", "index_pointing_at_the_viewer" => "🫵", + "thumbs_up" => "👍", "thumbs_down" => "👎", "raised_fist" => "✊", "oncoming_fist" => "👊", + "left_facing_fist" => "🤛", "right_facing_fist" => "🤜", "clapping_hands" => "👏", "raising_hands" => "🙌", + "heart_hands" => "🫶", "open_hands" => "👐", "palms_up_together" => "🤲", "handshake" => "🤝", + "folded_hands" => "🙏", "writing_hand" => "✍️", "nail_polish" => "💅", "selfie" => "🤳", + "flexed_biceps" => "💪", "mechanical_arm" => "🦾", "mechanical_leg" => "🦿", "leg" => "🦵", + "foot" => "🦶", "ear" => "👂", "ear_with_hearing_aid" => "🦻", "nose" => "👃", + "brain" => "🧠", "anatomical_heart" => "🫀", "lungs" => "🫁", "tooth" => "🦷", + "bone" => "🦴", "eyes" => "👀", "eye" => "👁️", "tongue" => "👅", + "mouth" => "👄", "biting_lip" => "🫦", "baby" => "👶", "child" => "🧒", + "boy" => "👦", "girl" => "👧", "person" => "🧑", "person_blond_hair" => "👱", + "man" => "👨", "person_beard" => "🧔", "man_beard" => "🧔‍♂️", "woman_beard" => "🧔‍♀️", + "man_red_hair" => "👨‍🦰", "man_curly_hair" => "👨‍🦱", "man_white_hair" => "👨‍🦳", "man_bald" => "👨‍🦲", + "woman" => "👩", "woman_red_hair" => "👩‍🦰", "person_red_hair" => "🧑‍🦰", "woman_curly_hair" => "👩‍🦱", + "person_curly_hair" => "🧑‍🦱", "woman_white_hair" => "👩‍🦳", "person_white_hair" => "🧑‍🦳", "woman_bald" => "👩‍🦲", + "person_bald" => "🧑‍🦲", "woman_blond_hair" => "👱‍♀️", "man_blond_hair" => "👱‍♂️", "older_person" => "🧓", + "old_man" => "👴", "old_woman" => "👵", "person_frowning" => "🙍", "man_frowning" => "🙍‍♂️", + "woman_frowning" => "🙍‍♀️", "person_pouting" => "🙎", "man_pouting" => "🙎‍♂️", "woman_pouting" => "🙎‍♀️", + "person_gesturing_no" => "🙅", "man_gesturing_no" => "🙅‍♂️", "woman_gesturing_no" => "🙅‍♀️", "person_gesturing_ok" => "🙆", + "man_gesturing_ok" => "🙆‍♂️", "woman_gesturing_ok" => "🙆‍♀️", "person_tipping_hand" => "💁", "man_tipping_hand" => "💁‍♂️", + "woman_tipping_hand" => "💁‍♀️", "person_raising_hand" => "🙋", "man_raising_hand" => "🙋‍♂️", "woman_raising_hand" => "🙋‍♀️", + "deaf_person" => "🧏", "deaf_man" => "🧏‍♂️", "deaf_woman" => "🧏‍♀️", "person_bowing" => "🙇", + "man_bowing" => "🙇‍♂️", "woman_bowing" => "🙇‍♀️", "person_facepalming" => "🤦", "man_facepalming" => "🤦‍♂️", + "woman_facepalming" => "🤦‍♀️", "person_shrugging" => "🤷", "man_shrugging" => "🤷‍♂️", "woman_shrugging" => "🤷‍♀️", + "health_worker" => "🧑‍⚕️", "man_health_worker" => "👨‍⚕️", "woman_health_worker" => "👩‍⚕️", "student" => "🧑‍🎓", + "man_student" => "👨‍🎓", "woman_student" => "👩‍🎓", "teacher" => "🧑‍🏫", "man_teacher" => "👨‍🏫", + "woman_teacher" => "👩‍🏫", "judge" => "🧑‍⚖️", "man_judge" => "👨‍⚖️", "woman_judge" => "👩‍⚖️", + "farmer" => "🧑‍🌾", "man_farmer" => "👨‍🌾", "woman_farmer" => "👩‍🌾", "cook" => "🧑‍🍳", + "man_cook" => "👨‍🍳", "woman_cook" => "👩‍🍳", "mechanic" => "🧑‍🔧", "man_mechanic" => "👨‍🔧", + "woman_mechanic" => "👩‍🔧", "factory_worker" => "🧑‍🏭", "man_factory_worker" => "👨‍🏭", "woman_factory_worker" => "👩‍🏭", + "office_worker" => "🧑‍💼", "man_office_worker" => "👨‍💼", "woman_office_worker" => "👩‍💼", "scientist" => "🧑‍🔬", + "man_scientist" => "👨‍🔬", "woman_scientist" => "👩‍🔬", "technologist" => "🧑‍💻", "man_technologist" => "👨‍💻", + "woman_technologist" => "👩‍💻", "singer" => "🧑‍🎤", "man_singer" => "👨‍🎤", "woman_singer" => "👩‍🎤", + "artist" => "🧑‍🎨", "man_artist" => "👨‍🎨", "woman_artist" => "👩‍🎨", "pilot" => "🧑‍✈️", + "man_pilot" => "👨‍✈️", "woman_pilot" => "👩‍✈️", "astronaut" => "🧑‍🚀", "man_astronaut" => "👨‍🚀", + "woman_astronaut" => "👩‍🚀", "firefighter" => "🧑‍🚒", "man_firefighter" => "👨‍🚒", "woman_firefighter" => "👩‍🚒", + "police_officer" => "👮", "man_police_officer" => "👮‍♂️", "woman_police_officer" => "👮‍♀️", "detective" => "🕵️", + "man_detective" => "🕵️‍♂️", "woman_detective" => "🕵️‍♀️", "guard" => "💂", "man_guard" => "💂‍♂️", + "woman_guard" => "💂‍♀️", "ninja" => "🥷", "construction_worker" => "👷", "man_construction_worker" => "👷‍♂️", + "woman_construction_worker" => "👷‍♀️", "person_with_crown" => "🫅", "prince" => "🤴", "princess" => "👸", + "person_wearing_turban" => "👳", "man_wearing_turban" => "👳‍♂️", "woman_wearing_turban" => "👳‍♀️", "person_with_skullcap" => "👲", + "woman_with_headscarf" => "🧕", "person_in_tuxedo" => "🤵", "man_in_tuxedo" => "🤵‍♂️", "woman_in_tuxedo" => "🤵‍♀️", + "person_with_veil" => "👰", "man_with_veil" => "👰‍♂️", "woman_with_veil" => "👰‍♀️", "pregnant_woman" => "🤰", + "pregnant_man" => "🫃", "pregnant_person" => "🫄", "breast_feeding" => "🤱", "woman_feeding_baby" => "👩‍🍼", + "man_feeding_baby" => "👨‍🍼", "person_feeding_baby" => "🧑‍🍼", "baby_angel" => "👼", "santa_claus" => "🎅", + "mrs_claus" => "🤶", "mx_claus" => "🧑‍🎄", "superhero" => "🦸", "man_superhero" => "🦸‍♂️", + "woman_superhero" => "🦸‍♀️", "supervillain" => "🦹", "man_supervillain" => "🦹‍♂️", "woman_supervillain" => "🦹‍♀️", + "mage" => "🧙", "man_mage" => "🧙‍♂️", "woman_mage" => "🧙‍♀️", "fairy" => "🧚", + "man_fairy" => "🧚‍♂️", "woman_fairy" => "🧚‍♀️", "vampire" => "🧛", "man_vampire" => "🧛‍♂️", + "woman_vampire" => "🧛‍♀️", "merperson" => "🧜", "merman" => "🧜‍♂️", "mermaid" => "🧜‍♀️", + "elf" => "🧝", "man_elf" => "🧝‍♂️", "woman_elf" => "🧝‍♀️", "genie" => "🧞", + "man_genie" => "🧞‍♂️", "woman_genie" => "🧞‍♀️", "zombie" => "🧟", "man_zombie" => "🧟‍♂️", + "woman_zombie" => "🧟‍♀️", "troll" => "🧌", "person_getting_massage" => "💆", "man_getting_massage" => "💆‍♂️", + "woman_getting_massage" => "💆‍♀️", "person_getting_haircut" => "💇", "man_getting_haircut" => "💇‍♂️", "woman_getting_haircut" => "💇‍♀️", + "person_walking" => "🚶", "man_walking" => "🚶‍♂️", "woman_walking" => "🚶‍♀️", "person_walking_facing_right" => "🚶‍➡️", + "woman_walking_facing_right" => "🚶‍♀️‍➡️", "man_walking_facing_right" => "🚶‍♂️‍➡️", "person_standing" => "🧍", "man_standing" => "🧍‍♂️", + "woman_standing" => "🧍‍♀️", "person_kneeling" => "🧎", "man_kneeling" => "🧎‍♂️", "woman_kneeling" => "🧎‍♀️", + "person_kneeling_facing_right" => "🧎‍➡️", "woman_kneeling_facing_right" => "🧎‍♀️‍➡️", "man_kneeling_facing_right" => "🧎‍♂️‍➡️", "person_with_white_cane" => "🧑‍🦯", + "person_with_white_cane_facing_right" => "🧑‍🦯‍➡️", "man_with_white_cane" => "👨‍🦯", "man_with_white_cane_facing_right" => "👨‍🦯‍➡️", "woman_with_white_cane" => "👩‍🦯", + "woman_with_white_cane_facing_right" => "👩‍🦯‍➡️", "person_in_motorized_wheelchair" => "🧑‍🦼", "person_in_motorized_wheelchair_facing_right" => "🧑‍🦼‍➡️", "man_in_motorized_wheelchair" => "👨‍🦼", + "man_in_motorized_wheelchair_facing_right" => "👨‍🦼‍➡️", "woman_in_motorized_wheelchair" => "👩‍🦼", "woman_in_motorized_wheelchair_facing_right" => "👩‍🦼‍➡️", "person_in_manual_wheelchair" => "🧑‍🦽", + "person_in_manual_wheelchair_facing_right" => "🧑‍🦽‍➡️", "man_in_manual_wheelchair" => "👨‍🦽", "man_in_manual_wheelchair_facing_right" => "👨‍🦽‍➡️", "woman_in_manual_wheelchair" => "👩‍🦽", + "woman_in_manual_wheelchair_facing_right" => "👩‍🦽‍➡️", "person_running" => "🏃", "man_running" => "🏃‍♂️", "woman_running" => "🏃‍♀️", + "person_running_facing_right" => "🏃‍➡️", "woman_running_facing_right" => "🏃‍♀️‍➡️", "man_running_facing_right" => "🏃‍♂️‍➡️", "woman_dancing" => "💃", + "man_dancing" => "🕺", "person_in_suit_levitating" => "🕴️", "people_with_bunny_ears" => "👯", "men_with_bunny_ears" => "👯‍♂️", + "women_with_bunny_ears" => "👯‍♀️", "person_in_steamy_room" => "🧖", "man_in_steamy_room" => "🧖‍♂️", "woman_in_steamy_room" => "🧖‍♀️", + "person_climbing" => "🧗", "man_climbing" => "🧗‍♂️", "woman_climbing" => "🧗‍♀️", "person_fencing" => "🤺", + "horse_racing" => "🏇", "skier" => "⛷️", "snowboarder" => "🏂", "person_golfing" => "🏌️", + "man_golfing" => "🏌️‍♂️", "woman_golfing" => "🏌️‍♀️", "person_surfing" => "🏄", "man_surfing" => "🏄‍♂️", + "woman_surfing" => "🏄‍♀️", "person_rowing_boat" => "🚣", "man_rowing_boat" => "🚣‍♂️", "woman_rowing_boat" => "🚣‍♀️", + "person_swimming" => "🏊", "man_swimming" => "🏊‍♂️", "woman_swimming" => "🏊‍♀️", "person_bouncing_ball" => "⛹️", + "man_bouncing_ball" => "⛹️‍♂️", "woman_bouncing_ball" => "⛹️‍♀️", "person_lifting_weights" => "🏋️", "man_lifting_weights" => "🏋️‍♂️", + "woman_lifting_weights" => "🏋️‍♀️", "person_biking" => "🚴", "man_biking" => "🚴‍♂️", "woman_biking" => "🚴‍♀️", + "person_mountain_biking" => "🚵", "man_mountain_biking" => "🚵‍♂️", "woman_mountain_biking" => "🚵‍♀️", "person_cartwheeling" => "🤸", + "man_cartwheeling" => "🤸‍♂️", "woman_cartwheeling" => "🤸‍♀️", "people_wrestling" => "🤼", "men_wrestling" => "🤼‍♂️", + "women_wrestling" => "🤼‍♀️", "person_playing_water_polo" => "🤽", "man_playing_water_polo" => "🤽‍♂️", "woman_playing_water_polo" => "🤽‍♀️", + "person_playing_handball" => "🤾", "man_playing_handball" => "🤾‍♂️", "woman_playing_handball" => "🤾‍♀️", "person_juggling" => "🤹", + "man_juggling" => "🤹‍♂️", "woman_juggling" => "🤹‍♀️", "person_in_lotus_position" => "🧘", "man_in_lotus_position" => "🧘‍♂️", + "woman_in_lotus_position" => "🧘‍♀️", "person_taking_bath" => "🛀", "person_in_bed" => "🛌", "people_holding_hands" => "🧑‍🤝‍🧑", + "women_holding_hands" => "👭", "woman_and_man_holding_hands" => "👫", "men_holding_hands" => "👬", "kiss" => "💏", + "kiss_woman_man" => "👩‍❤️‍💋‍👨", "kiss_man_man" => "👨‍❤️‍💋‍👨", "kiss_woman_woman" => "👩‍❤️‍💋‍👩", "couple_with_heart" => "💑", + "couple_with_heart_woman_man" => "👩‍❤️‍👨", "couple_with_heart_man_man" => "👨‍❤️‍👨", "couple_with_heart_woman_woman" => "👩‍❤️‍👩", "family_man_woman_boy" => "👨‍👩‍👦", + "family_man_woman_girl" => "👨‍👩‍👧", "family_man_woman_girl_boy" => "👨‍👩‍👧‍👦", "family_man_woman_boy_boy" => "👨‍👩‍👦‍👦", "family_man_woman_girl_girl" => "👨‍👩‍👧‍👧", + "family_man_man_boy" => "👨‍👨‍👦", "family_man_man_girl" => "👨‍👨‍👧", "family_man_man_girl_boy" => "👨‍👨‍👧‍👦", "family_man_man_boy_boy" => "👨‍👨‍👦‍👦", + "family_man_man_girl_girl" => "👨‍👨‍👧‍👧", "family_woman_woman_boy" => "👩‍👩‍👦", "family_woman_woman_girl" => "👩‍👩‍👧", "family_woman_woman_girl_boy" => "👩‍👩‍👧‍👦", + "family_woman_woman_boy_boy" => "👩‍👩‍👦‍👦", "family_woman_woman_girl_girl" => "👩‍👩‍👧‍👧", "family_man_boy" => "👨‍👦", "family_man_boy_boy" => "👨‍👦‍👦", + "family_man_girl" => "👨‍👧", "family_man_girl_boy" => "👨‍👧‍👦", "family_man_girl_girl" => "👨‍👧‍👧", "family_woman_boy" => "👩‍👦", + "family_woman_boy_boy" => "👩‍👦‍👦", "family_woman_girl" => "👩‍👧", "family_woman_girl_boy" => "👩‍👧‍👦", "family_woman_girl_girl" => "👩‍👧‍👧", + "speaking_head" => "🗣️", "bust_in_silhouette" => "👤", "busts_in_silhouette" => "👥", "people_hugging" => "🫂", + "family" => "👪", "family_adult_adult_child" => "🧑‍🧑‍🧒", "family_adult_adult_child_child" => "🧑‍🧑‍🧒‍🧒", "family_adult_child" => "🧑‍🧒", + "family_adult_child_child" => "🧑‍🧒‍🧒", "footprints" => "👣", "fingerprint" => "🫆", "monkey_face" => "🐵", + "monkey" => "🐒", "gorilla" => "🦍", "orangutan" => "🦧", "dog_face" => "🐶", + "dog" => "🐕", "guide_dog" => "🦮", "service_dog" => "🐕‍🦺", "poodle" => "🐩", + "wolf" => "🐺", "fox" => "🦊", "raccoon" => "🦝", "cat_face" => "🐱", + "cat" => "🐈", "black_cat" => "🐈‍⬛", "lion" => "🦁", "tiger_face" => "🐯", + "tiger" => "🐅", "leopard" => "🐆", "horse_face" => "🐴", "moose" => "🫎", + "donkey" => "🫏", "horse" => "🐎", "unicorn" => "🦄", "zebra" => "🦓", + "deer" => "🦌", "bison" => "🦬", "cow_face" => "🐮", "ox" => "🐂", + "water_buffalo" => "🐃", "cow" => "🐄", "pig_face" => "🐷", "pig" => "🐖", + "boar" => "🐗", "pig_nose" => "🐽", "ram" => "🐏", "ewe" => "🐑", + "goat" => "🐐", "camel" => "🐪", "two_hump_camel" => "🐫", "llama" => "🦙", + "giraffe" => "🦒", "elephant" => "🐘", "mammoth" => "🦣", "rhinoceros" => "🦏", + "hippopotamus" => "🦛", "mouse_face" => "🐭", "mouse" => "🐁", "rat" => "🐀", + "hamster" => "🐹", "rabbit_face" => "🐰", "rabbit" => "🐇", "chipmunk" => "🐿️", + "beaver" => "🦫", "hedgehog" => "🦔", "bat" => "🦇", "bear" => "🐻", + "polar_bear" => "🐻‍❄️", "koala" => "🐨", "panda" => "🐼", "sloth" => "🦥", + "otter" => "🦦", "skunk" => "🦨", "kangaroo" => "🦘", "badger" => "🦡", + "paw_prints" => "🐾", "turkey" => "🦃", "chicken" => "🐔", "rooster" => "🐓", + "hatching_chick" => "🐣", "baby_chick" => "🐤", "front_facing_baby_chick" => "🐥", "bird" => "🐦", + "penguin" => "🐧", "dove" => "🕊️", "eagle" => "🦅", "duck" => "🦆", + "swan" => "🦢", "owl" => "🦉", "dodo" => "🦤", "feather" => "🪶", + "flamingo" => "🦩", "peacock" => "🦚", "parrot" => "🦜", "wing" => "🪽", + "black_bird" => "🐦‍⬛", "goose" => "🪿", "phoenix" => "🐦‍🔥", "frog" => "🐸", + "crocodile" => "🐊", "turtle" => "🐢", "lizard" => "🦎", "snake" => "🐍", + "dragon_face" => "🐲", "dragon" => "🐉", "sauropod" => "🦕", "t_rex" => "🦖", + "spouting_whale" => "🐳", "whale" => "🐋", "dolphin" => "🐬", "seal" => "🦭", + "fish" => "🐟", "tropical_fish" => "🐠", "blowfish" => "🐡", "shark" => "🦈", + "octopus" => "🐙", "spiral_shell" => "🐚", "coral" => "🪸", "jellyfish" => "🪼", + "crab" => "🦀", "lobster" => "🦞", "shrimp" => "🦐", "squid" => "🦑", + "oyster" => "🦪", "snail" => "🐌", "butterfly" => "🦋", "bug" => "🐛", + "ant" => "🐜", "honeybee" => "🐝", "beetle" => "🪲", "lady_beetle" => "🐞", + "cricket" => "🦗", "cockroach" => "🪳", "spider" => "🕷️", "spider_web" => "🕸️", + "scorpion" => "🦂", "mosquito" => "🦟", "fly" => "🪰", "worm" => "🪱", + "microbe" => "🦠", "bouquet" => "💐", "cherry_blossom" => "🌸", "white_flower" => "💮", + "lotus" => "🪷", "rosette" => "🏵️", "rose" => "🌹", "wilted_flower" => "🥀", + "hibiscus" => "🌺", "sunflower" => "🌻", "blossom" => "🌼", "tulip" => "🌷", + "hyacinth" => "🪻", "seedling" => "🌱", "potted_plant" => "🪴", "evergreen_tree" => "🌲", + "deciduous_tree" => "🌳", "palm_tree" => "🌴", "cactus" => "🌵", "sheaf_of_rice" => "🌾", + "herb" => "🌿", "shamrock" => "☘️", "four_leaf_clover" => "🍀", "maple_leaf" => "🍁", + "fallen_leaf" => "🍂", "leaf_fluttering_in_wind" => "🍃", "empty_nest" => "🪹", "nest_with_eggs" => "🪺", + "mushroom" => "🍄", "leafless_tree" => "🪾", "grapes" => "🍇", "melon" => "🍈", + "watermelon" => "🍉", "tangerine" => "🍊", "lemon" => "🍋", "lime" => "🍋‍🟩", + "banana" => "🍌", "pineapple" => "🍍", "mango" => "🥭", "red_apple" => "🍎", + "green_apple" => "🍏", "pear" => "🍐", "peach" => "🍑", "cherries" => "🍒", + "strawberry" => "🍓", "blueberries" => "🫐", "kiwi_fruit" => "🥝", "tomato" => "🍅", + "olive" => "🫒", "coconut" => "🥥", "avocado" => "🥑", "eggplant" => "🍆", + "potato" => "🥔", "carrot" => "🥕", "ear_of_corn" => "🌽", "hot_pepper" => "🌶️", + "bell_pepper" => "🫑", "cucumber" => "🥒", "leafy_green" => "🥬", "broccoli" => "🥦", + "garlic" => "🧄", "onion" => "🧅", "peanuts" => "🥜", "beans" => "🫘", + "chestnut" => "🌰", "ginger_root" => "🫚", "pea_pod" => "🫛", "brown_mushroom" => "🍄‍🟫", + "root_vegetable" => "🫜", "bread" => "🍞", "croissant" => "🥐", "baguette_bread" => "🥖", + "flatbread" => "🫓", "pretzel" => "🥨", "bagel" => "🥯", "pancakes" => "🥞", + "waffle" => "🧇", "cheese_wedge" => "🧀", "meat_on_bone" => "🍖", "poultry_leg" => "🍗", + "cut_of_meat" => "🥩", "bacon" => "🥓", "hamburger" => "🍔", "french_fries" => "🍟", + "pizza" => "🍕", "hot_dog" => "🌭", "sandwich" => "🥪", "taco" => "🌮", + "burrito" => "🌯", "tamale" => "🫔", "stuffed_flatbread" => "🥙", "falafel" => "🧆", + "egg" => "🥚", "cooking" => "🍳", "shallow_pan_of_food" => "🥘", "pot_of_food" => "🍲", + "fondue" => "🫕", "bowl_with_spoon" => "🥣", "green_salad" => "🥗", "popcorn" => "🍿", + "butter" => "🧈", "salt" => "🧂", "canned_food" => "🥫", "bento_box" => "🍱", + "rice_cracker" => "🍘", "rice_ball" => "🍙", "cooked_rice" => "🍚", "curry_rice" => "🍛", + "steaming_bowl" => "🍜", "spaghetti" => "🍝", "roasted_sweet_potato" => "🍠", "oden" => "🍢", + "sushi" => "🍣", "fried_shrimp" => "🍤", "fish_cake_with_swirl" => "🍥", "moon_cake" => "🥮", + "dango" => "🍡", "dumpling" => "🥟", "fortune_cookie" => "🥠", "takeout_box" => "🥡", + "soft_ice_cream" => "🍦", "shaved_ice" => "🍧", "ice_cream" => "🍨", "doughnut" => "🍩", + "cookie" => "🍪", "birthday_cake" => "🎂", "shortcake" => "🍰", "cupcake" => "🧁", + "pie" => "🥧", "chocolate_bar" => "🍫", "candy" => "🍬", "lollipop" => "🍭", + "custard" => "🍮", "honey_pot" => "🍯", "baby_bottle" => "🍼", "glass_of_milk" => "🥛", + "hot_beverage" => "☕", "teapot" => "🫖", "teacup_without_handle" => "🍵", "sake" => "🍶", + "bottle_with_popping_cork" => "🍾", "wine_glass" => "🍷", "cocktail_glass" => "🍸", "tropical_drink" => "🍹", + "beer_mug" => "🍺", "clinking_beer_mugs" => "🍻", "clinking_glasses" => "🥂", "tumbler_glass" => "🥃", + "pouring_liquid" => "🫗", "cup_with_straw" => "🥤", "bubble_tea" => "🧋", "beverage_box" => "🧃", + "mate" => "🧉", "ice" => "🧊", "chopsticks" => "🥢", "fork_and_knife_with_plate" => "🍽️", + "fork_and_knife" => "🍴", "spoon" => "🥄", "kitchen_knife" => "🔪", "jar" => "🫙", + "amphora" => "🏺", "globe_showing_europe_africa" => "🌍", "globe_showing_americas" => "🌎", "globe_showing_asia_australia" => "🌏", + "globe_with_meridians" => "🌐", "world_map" => "🗺️", "map_of_japan" => "🗾", "compass" => "🧭", + "snow_capped_mountain" => "🏔️", "mountain" => "⛰️", "volcano" => "🌋", "mount_fuji" => "🗻", + "camping" => "🏕️", "beach_with_umbrella" => "🏖️", "desert" => "🏜️", "desert_island" => "🏝️", + "national_park" => "🏞️", "stadium" => "🏟️", "classical_building" => "🏛️", "building_construction" => "🏗️", + "brick" => "🧱", "rock" => "🪨", "wood" => "🪵", "hut" => "🛖", + "houses" => "🏘️", "derelict_house" => "🏚️", "house" => "🏠", "house_with_garden" => "🏡", + "office_building" => "🏢", "japanese_post_office" => "🏣", "post_office" => "🏤", "hospital" => "🏥", + "bank" => "🏦", "hotel" => "🏨", "love_hotel" => "🏩", "convenience_store" => "🏪", + "school" => "🏫", "department_store" => "🏬", "factory" => "🏭", "japanese_castle" => "🏯", + "castle" => "🏰", "wedding" => "💒", "tokyo_tower" => "🗼", "statue_of_liberty" => "🗽", + "church" => "⛪", "mosque" => "🕌", "hindu_temple" => "🛕", "synagogue" => "🕍", + "shinto_shrine" => "⛩️", "kaaba" => "🕋", "fountain" => "⛲", "tent" => "⛺", + "foggy" => "🌁", "night_with_stars" => "🌃", "cityscape" => "🏙️", "sunrise_over_mountains" => "🌄", + "sunrise" => "🌅", "cityscape_at_dusk" => "🌆", "sunset" => "🌇", "bridge_at_night" => "🌉", + "hot_springs" => "♨️", "carousel_horse" => "🎠", "playground_slide" => "🛝", "ferris_wheel" => "🎡", + "roller_coaster" => "🎢", "barber_pole" => "💈", "circus_tent" => "🎪", "locomotive" => "🚂", + "railway_car" => "🚃", "high_speed_train" => "🚄", "bullet_train" => "🚅", "train" => "🚆", + "metro" => "🚇", "light_rail" => "🚈", "station" => "🚉", "tram" => "🚊", + "monorail" => "🚝", "mountain_railway" => "🚞", "tram_car" => "🚋", "bus" => "🚌", + "oncoming_bus" => "🚍", "trolleybus" => "🚎", "minibus" => "🚐", "ambulance" => "🚑", + "fire_engine" => "🚒", "police_car" => "🚓", "oncoming_police_car" => "🚔", "taxi" => "🚕", + "oncoming_taxi" => "🚖", "automobile" => "🚗", "oncoming_automobile" => "🚘", "sport_utility_vehicle" => "🚙", + "pickup_truck" => "🛻", "delivery_truck" => "🚚", "articulated_lorry" => "🚛", "tractor" => "🚜", + "racing_car" => "🏎️", "motorcycle" => "🏍️", "motor_scooter" => "🛵", "manual_wheelchair" => "🦽", + "motorized_wheelchair" => "🦼", "auto_rickshaw" => "🛺", "bicycle" => "🚲", "kick_scooter" => "🛴", + "skateboard" => "🛹", "roller_skate" => "🛼", "bus_stop" => "🚏", "motorway" => "🛣️", + "railway_track" => "🛤️", "oil_drum" => "🛢️", "fuel_pump" => "⛽", "wheel" => "🛞", + "police_car_light" => "🚨", "horizontal_traffic_light" => "🚥", "vertical_traffic_light" => "🚦", "stop_sign" => "🛑", + "construction" => "🚧", "anchor" => "⚓", "ring_buoy" => "🛟", "sailboat" => "⛵", + "canoe" => "🛶", "speedboat" => "🚤", "passenger_ship" => "🛳️", "ferry" => "⛴️", + "motor_boat" => "🛥️", "ship" => "🚢", "airplane" => "✈️", "small_airplane" => "🛩️", + "airplane_departure" => "🛫", "airplane_arrival" => "🛬", "parachute" => "🪂", "seat" => "💺", + "helicopter" => "🚁", "suspension_railway" => "🚟", "mountain_cableway" => "🚠", "aerial_tramway" => "🚡", + "satellite" => "🛰️", "rocket" => "🚀", "flying_saucer" => "🛸", "bellhop_bell" => "🛎️", + "luggage" => "🧳", "hourglass_done" => "⌛", "hourglass_not_done" => "⏳", "watch" => "⌚", + "alarm_clock" => "⏰", "stopwatch" => "⏱️", "timer_clock" => "⏲️", "mantelpiece_clock" => "🕰️", + "twelve_o_clock" => "🕛", "twelve_thirty" => "🕧", "one_o_clock" => "🕐", "one_thirty" => "🕜", + "two_o_clock" => "🕑", "two_thirty" => "🕝", "three_o_clock" => "🕒", "three_thirty" => "🕞", + "four_o_clock" => "🕓", "four_thirty" => "🕟", "five_o_clock" => "🕔", "five_thirty" => "🕠", + "six_o_clock" => "🕕", "six_thirty" => "🕡", "seven_o_clock" => "🕖", "seven_thirty" => "🕢", + "eight_o_clock" => "🕗", "eight_thirty" => "🕣", "nine_o_clock" => "🕘", "nine_thirty" => "🕤", + "ten_o_clock" => "🕙", "ten_thirty" => "🕥", "eleven_o_clock" => "🕚", "eleven_thirty" => "🕦", + "new_moon" => "🌑", "waxing_crescent_moon" => "🌒", "first_quarter_moon" => "🌓", "waxing_gibbous_moon" => "🌔", + "full_moon" => "🌕", "waning_gibbous_moon" => "🌖", "last_quarter_moon" => "🌗", "waning_crescent_moon" => "🌘", + "crescent_moon" => "🌙", "new_moon_face" => "🌚", "first_quarter_moon_face" => "🌛", "last_quarter_moon_face" => "🌜", + "thermometer" => "🌡️", "sun" => "☀️", "full_moon_face" => "🌝", "sun_with_face" => "🌞", + "ringed_planet" => "🪐", "star" => "⭐", "glowing_star" => "🌟", "shooting_star" => "🌠", + "milky_way" => "🌌", "cloud" => "☁️", "sun_behind_cloud" => "⛅", "cloud_with_lightning_and_rain" => "⛈️", + "sun_behind_small_cloud" => "🌤️", "sun_behind_large_cloud" => "🌥️", "sun_behind_rain_cloud" => "🌦️", "cloud_with_rain" => "🌧️", + "cloud_with_snow" => "🌨️", "cloud_with_lightning" => "🌩️", "tornado" => "🌪️", "fog" => "🌫️", + "wind_face" => "🌬️", "cyclone" => "🌀", "rainbow" => "🌈", "closed_umbrella" => "🌂", + "umbrella" => "☂️", "umbrella_with_rain_drops" => "☔", "umbrella_on_ground" => "⛱️", "high_voltage" => "⚡", + "snowflake" => "❄️", "snowman" => "☃️", "snowman_without_snow" => "⛄", "comet" => "☄️", + "fire" => "🔥", "droplet" => "💧", "water_wave" => "🌊", "jack_o_lantern" => "🎃", + "christmas_tree" => "🎄", "fireworks" => "🎆", "sparkler" => "🎇", "firecracker" => "🧨", + "sparkles" => "✨", "balloon" => "🎈", "party_popper" => "🎉", "confetti_ball" => "🎊", + "tanabata_tree" => "🎋", "pine_decoration" => "🎍", "japanese_dolls" => "🎎", "carp_streamer" => "🎏", + "wind_chime" => "🎐", "moon_viewing_ceremony" => "🎑", "red_envelope" => "🧧", "ribbon" => "🎀", + "wrapped_gift" => "🎁", "reminder_ribbon" => "🎗️", "admission_tickets" => "🎟️", "ticket" => "🎫", + "military_medal" => "🎖️", "trophy" => "🏆", "sports_medal" => "🏅", "1st_place_medal" => "🥇", + "2nd_place_medal" => "🥈", "3rd_place_medal" => "🥉", "soccer_ball" => "⚽", "baseball" => "⚾", + "softball" => "🥎", "basketball" => "🏀", "volleyball" => "🏐", "american_football" => "🏈", + "rugby_football" => "🏉", "tennis" => "🎾", "flying_disc" => "🥏", "bowling" => "🎳", + "cricket_game" => "🏏", "field_hockey" => "🏑", "ice_hockey" => "🏒", "lacrosse" => "🥍", + "ping_pong" => "🏓", "badminton" => "🏸", "boxing_glove" => "🥊", "martial_arts_uniform" => "🥋", + "goal_net" => "🥅", "flag_in_hole" => "⛳", "ice_skate" => "⛸️", "fishing_pole" => "🎣", + "diving_mask" => "🤿", "running_shirt" => "🎽", "skis" => "🎿", "sled" => "🛷", + "curling_stone" => "🥌", "bullseye" => "🎯", "yo_yo" => "🪀", "kite" => "🪁", + "water_pistol" => "🔫", "pool_8_ball" => "🎱", "crystal_ball" => "🔮", "magic_wand" => "🪄", + "video_game" => "🎮", "joystick" => "🕹️", "slot_machine" => "🎰", "game_die" => "🎲", + "puzzle_piece" => "🧩", "teddy_bear" => "🧸", "pinata" => "🪅", "mirror_ball" => "🪩", + "nesting_dolls" => "🪆", "spade_suit" => "♠️", "heart_suit" => "♥️", "diamond_suit" => "♦️", + "club_suit" => "♣️", "chess_pawn" => "♟️", "joker" => "🃏", "mahjong_red_dragon" => "🀄", + "flower_playing_cards" => "🎴", "performing_arts" => "🎭", "framed_picture" => "🖼️", "artist_palette" => "🎨", + "thread" => "🧵", "sewing_needle" => "🪡", "yarn" => "🧶", "knot" => "🪢", + "glasses" => "👓", "sunglasses" => "🕶️", "goggles" => "🥽", "lab_coat" => "🥼", + "safety_vest" => "🦺", "necktie" => "👔", "t_shirt" => "👕", "jeans" => "👖", + "scarf" => "🧣", "gloves" => "🧤", "coat" => "🧥", "socks" => "🧦", + "dress" => "👗", "kimono" => "👘", "sari" => "🥻", "one_piece_swimsuit" => "🩱", + "briefs" => "🩲", "shorts" => "🩳", "bikini" => "👙", "woman_s_clothes" => "👚", + "folding_hand_fan" => "🪭", "purse" => "👛", "handbag" => "👜", "clutch_bag" => "👝", + "shopping_bags" => "🛍️", "backpack" => "🎒", "thong_sandal" => "🩴", "man_s_shoe" => "👞", + "running_shoe" => "👟", "hiking_boot" => "🥾", "flat_shoe" => "🥿", "high_heeled_shoe" => "👠", + "woman_s_sandal" => "👡", "ballet_shoes" => "🩰", "woman_s_boot" => "👢", "hair_pick" => "🪮", + "crown" => "👑", "woman_s_hat" => "👒", "top_hat" => "🎩", "graduation_cap" => "🎓", + "billed_cap" => "🧢", "military_helmet" => "🪖", "rescue_worker_s_helmet" => "⛑️", "prayer_beads" => "📿", + "lipstick" => "💄", "ring" => "💍", "gem_stone" => "💎", "muted_speaker" => "🔇", + "speaker_low_volume" => "🔈", "speaker_medium_volume" => "🔉", "speaker_high_volume" => "🔊", "loudspeaker" => "📢", + "megaphone" => "📣", "postal_horn" => "📯", "bell" => "🔔", "bell_with_slash" => "🔕", + "musical_score" => "🎼", "musical_note" => "🎵", "musical_notes" => "🎶", "studio_microphone" => "🎙️", + "level_slider" => "🎚️", "control_knobs" => "🎛️", "microphone" => "🎤", "headphone" => "🎧", + "radio" => "📻", "saxophone" => "🎷", "accordion" => "🪗", "guitar" => "🎸", + "musical_keyboard" => "🎹", "trumpet" => "🎺", "violin" => "🎻", "banjo" => "🪕", + "drum" => "🥁", "long_drum" => "🪘", "maracas" => "🪇", "flute" => "🪈", + "harp" => "🪉", "mobile_phone" => "📱", "mobile_phone_with_arrow" => "📲", "telephone" => "☎️", + "telephone_receiver" => "📞", "pager" => "📟", "fax_machine" => "📠", "battery" => "🔋", + "low_battery" => "🪫", "electric_plug" => "🔌", "laptop" => "💻", "desktop_computer" => "🖥️", + "printer" => "🖨️", "keyboard" => "⌨️", "computer_mouse" => "🖱️", "trackball" => "🖲️", + "computer_disk" => "💽", "floppy_disk" => "💾", "optical_disk" => "💿", "dvd" => "📀", + "abacus" => "🧮", "movie_camera" => "🎥", "film_frames" => "🎞️", "film_projector" => "📽️", + "clapper_board" => "🎬", "television" => "📺", "camera" => "📷", "camera_with_flash" => "📸", + "video_camera" => "📹", "videocassette" => "📼", "magnifying_glass_tilted_left" => "🔍", "magnifying_glass_tilted_right" => "🔎", + "candle" => "🕯️", "light_bulb" => "💡", "flashlight" => "🔦", "red_paper_lantern" => "🏮", + "diya_lamp" => "🪔", "notebook_with_decorative_cover" => "📔", "closed_book" => "📕", "open_book" => "📖", + "green_book" => "📗", "blue_book" => "📘", "orange_book" => "📙", "books" => "📚", + "notebook" => "📓", "ledger" => "📒", "page_with_curl" => "📃", "scroll" => "📜", + "page_facing_up" => "📄", "newspaper" => "📰", "rolled_up_newspaper" => "🗞️", "bookmark_tabs" => "📑", + "bookmark" => "🔖", "label" => "🏷️", "money_bag" => "💰", "coin" => "🪙", + "yen_banknote" => "💴", "dollar_banknote" => "💵", "euro_banknote" => "💶", "pound_banknote" => "💷", + "money_with_wings" => "💸", "credit_card" => "💳", "receipt" => "🧾", "chart_increasing_with_yen" => "💹", + "envelope" => "✉️", "e_mail" => "📧", "incoming_envelope" => "📨", "envelope_with_arrow" => "📩", + "outbox_tray" => "📤", "inbox_tray" => "📥", "package" => "📦", "closed_mailbox_with_raised_flag" => "📫", + "closed_mailbox_with_lowered_flag" => "📪", "open_mailbox_with_raised_flag" => "📬", "open_mailbox_with_lowered_flag" => "📭", "postbox" => "📮", + "ballot_box_with_ballot" => "🗳️", "pencil" => "✏️", "black_nib" => "✒️", "fountain_pen" => "🖋️", + "pen" => "🖊️", "paintbrush" => "🖌️", "crayon" => "🖍️", "memo" => "📝", + "briefcase" => "💼", "file_folder" => "📁", "open_file_folder" => "📂", "card_index_dividers" => "🗂️", + "calendar" => "📅", "tear_off_calendar" => "📆", "spiral_notepad" => "🗒️", "spiral_calendar" => "🗓️", + "card_index" => "📇", "chart_increasing" => "📈", "chart_decreasing" => "📉", "bar_chart" => "📊", + "clipboard" => "📋", "pushpin" => "📌", "round_pushpin" => "📍", "paperclip" => "📎", + "linked_paperclips" => "🖇️", "straight_ruler" => "📏", "triangular_ruler" => "📐", "scissors" => "✂️", + "card_file_box" => "🗃️", "file_cabinet" => "🗄️", "wastebasket" => "🗑️", "locked" => "🔒", + "unlocked" => "🔓", "locked_with_pen" => "🔏", "locked_with_key" => "🔐", "key" => "🔑", + "old_key" => "🗝️", "hammer" => "🔨", "axe" => "🪓", "pick" => "⛏️", + "hammer_and_pick" => "⚒️", "hammer_and_wrench" => "🛠️", "dagger" => "🗡️", "crossed_swords" => "⚔️", + "bomb" => "💣", "boomerang" => "🪃", "bow_and_arrow" => "🏹", "shield" => "🛡️", + "carpentry_saw" => "🪚", "wrench" => "🔧", "screwdriver" => "🪛", "nut_and_bolt" => "🔩", + "gear" => "⚙️", "clamp" => "🗜️", "balance_scale" => "⚖️", "white_cane" => "🦯", + "link" => "🔗", "broken_chain" => "⛓️‍💥", "chains" => "⛓️", "hook" => "🪝", + "toolbox" => "🧰", "magnet" => "🧲", "ladder" => "🪜", "shovel" => "🪏", + "alembic" => "⚗️", "test_tube" => "🧪", "petri_dish" => "🧫", "dna" => "🧬", + "microscope" => "🔬", "telescope" => "🔭", "satellite_antenna" => "📡", "syringe" => "💉", + "drop_of_blood" => "🩸", "pill" => "💊", "adhesive_bandage" => "🩹", "crutch" => "🩼", + "stethoscope" => "🩺", "x_ray" => "🩻", "door" => "🚪", "elevator" => "🛗", + "mirror" => "🪞", "window" => "🪟", "bed" => "🛏️", "couch_and_lamp" => "🛋️", + "chair" => "🪑", "toilet" => "🚽", "plunger" => "🪠", "shower" => "🚿", + "bathtub" => "🛁", "mouse_trap" => "🪤", "razor" => "🪒", "lotion_bottle" => "🧴", + "safety_pin" => "🧷", "broom" => "🧹", "basket" => "🧺", "roll_of_paper" => "🧻", + "bucket" => "🪣", "soap" => "🧼", "bubbles" => "🫧", "toothbrush" => "🪥", + "sponge" => "🧽", "fire_extinguisher" => "🧯", "shopping_cart" => "🛒", "cigarette" => "🚬", + "coffin" => "⚰️", "headstone" => "🪦", "funeral_urn" => "⚱️", "nazar_amulet" => "🧿", + "hamsa" => "🪬", "moai" => "🗿", "placard" => "🪧", "identification_card" => "🪪", + "atm_sign" => "🏧", "litter_in_bin_sign" => "🚮", "potable_water" => "🚰", "wheelchair_symbol" => "♿", + "men_s_room" => "🚹", "women_s_room" => "🚺", "restroom" => "🚻", "baby_symbol" => "🚼", + "water_closet" => "🚾", "passport_control" => "🛂", "customs" => "🛃", "baggage_claim" => "🛄", + "left_luggage" => "🛅", "warning" => "⚠️", "children_crossing" => "🚸", "no_entry" => "⛔", + "prohibited" => "🚫", "no_bicycles" => "🚳", "no_smoking" => "🚭", "no_littering" => "🚯", + "non_potable_water" => "🚱", "no_pedestrians" => "🚷", "no_mobile_phones" => "📵", "no_one_under_eighteen" => "🔞", + "radioactive" => "☢️", "biohazard" => "☣️", "up_arrow" => "⬆️", "up_right_arrow" => "↗️", + "right_arrow" => "➡️", "down_right_arrow" => "↘️", "down_arrow" => "⬇️", "down_left_arrow" => "↙️", + "left_arrow" => "⬅️", "up_left_arrow" => "↖️", "up_down_arrow" => "↕️", "left_right_arrow" => "↔️", + "right_arrow_curving_left" => "↩️", "left_arrow_curving_right" => "↪️", "right_arrow_curving_up" => "⤴️", "right_arrow_curving_down" => "⤵️", + "clockwise_vertical_arrows" => "🔃", "counterclockwise_arrows_button" => "🔄", "back_arrow" => "🔙", "end_arrow" => "🔚", + "on_arrow" => "🔛", "soon_arrow" => "🔜", "top_arrow" => "🔝", "place_of_worship" => "🛐", + "atom_symbol" => "⚛️", "om" => "🕉️", "star_of_david" => "✡️", "wheel_of_dharma" => "☸️", + "yin_yang" => "☯️", "latin_cross" => "✝️", "orthodox_cross" => "☦️", "star_and_crescent" => "☪️", + "peace_symbol" => "☮️", "menorah" => "🕎", "dotted_six_pointed_star" => "🔯", "khanda" => "🪯", + "aries" => "♈", "taurus" => "♉", "gemini" => "♊", "cancer" => "♋", + "leo" => "♌", "virgo" => "♍", "libra" => "♎", "scorpio" => "♏", + "sagittarius" => "♐", "capricorn" => "♑", "aquarius" => "♒", "pisces" => "♓", + "ophiuchus" => "⛎", "shuffle_tracks_button" => "🔀", "repeat_button" => "🔁", "repeat_single_button" => "🔂", + "play_button" => "▶️", "fast_forward_button" => "⏩", "next_track_button" => "⏭️", "play_or_pause_button" => "⏯️", + "reverse_button" => "◀️", "fast_reverse_button" => "⏪", "last_track_button" => "⏮️", "upwards_button" => "🔼", + "fast_up_button" => "⏫", "downwards_button" => "🔽", "fast_down_button" => "⏬", "pause_button" => "⏸️", + "stop_button" => "⏹️", "record_button" => "⏺️", "eject_button" => "⏏️", "cinema" => "🎦", + "dim_button" => "🔅", "bright_button" => "🔆", "antenna_bars" => "📶", "wireless" => "🛜", + "vibration_mode" => "📳", "mobile_phone_off" => "📴", "female_sign" => "♀️", "male_sign" => "♂️", + "transgender_symbol" => "⚧️", "multiply" => "✖️", "plus" => "➕", "minus" => "➖", + "divide" => "➗", "heavy_equals_sign" => "🟰", "infinity" => "♾️", "double_exclamation_mark" => "‼️", + "exclamation_question_mark" => "⁉️", "red_question_mark" => "❓", "white_question_mark" => "❔", "white_exclamation_mark" => "❕", + "red_exclamation_mark" => "❗", "wavy_dash" => "〰️", "currency_exchange" => "💱", "heavy_dollar_sign" => "💲", + "medical_symbol" => "⚕️", "recycling_symbol" => "♻️", "fleur_de_lis" => "⚜️", "trident_emblem" => "🔱", + "name_badge" => "📛", "japanese_symbol_for_beginner" => "🔰", "hollow_red_circle" => "⭕", "check_mark_button" => "✅", + "check_box_with_check" => "☑️", "check_mark" => "✔️", "cross_mark" => "❌", "cross_mark_button" => "❎", + "curly_loop" => "➰", "double_curly_loop" => "➿", "part_alternation_mark" => "〽️", "eight_spoked_asterisk" => "✳️", + "eight_pointed_star" => "✴️", "sparkle" => "❇️", "copyright" => "©️", "registered" => "®️", + "trade_mark" => "™️", "splatter" => "🫟", "keycap_number_sign" => "#️⃣", "keycap_asterisk" => "*️⃣", + "keycap_0" => "0️⃣", "keycap_1" => "1️⃣", "keycap_2" => "2️⃣", "keycap_3" => "3️⃣", + "keycap_4" => "4️⃣", "keycap_5" => "5️⃣", "keycap_6" => "6️⃣", "keycap_7" => "7️⃣", + "keycap_8" => "8️⃣", "keycap_9" => "9️⃣", "keycap_10" => "🔟", "input_latin_uppercase" => "🔠", + "input_latin_lowercase" => "🔡", "input_numbers" => "🔢", "input_symbols" => "🔣", "input_latin_letters" => "🔤", + "a_button" => "🅰️", "ab_button" => "🆎", "b_button" => "🅱️", "cl_button" => "🆑", + "cool_button" => "🆒", "free_button" => "🆓", "information" => "ℹ️", "id_button" => "🆔", + "circled_m" => "Ⓜ️", "new_button" => "🆕", "ng_button" => "🆖", "o_button" => "🅾️", + "ok_button" => "🆗", "p_button" => "🅿️", "sos_button" => "🆘", "up_button" => "🆙", + "vs_button" => "🆚", "japanese_here_button" => "🈁", "japanese_service_charge_button" => "🈂️", "japanese_monthly_amount_button" => "🈷️", + "japanese_not_free_of_charge_button" => "🈶", "japanese_reserved_button" => "🈯", "japanese_bargain_button" => "🉐", "japanese_discount_button" => "🈹", + "japanese_free_of_charge_button" => "🈚", "japanese_prohibited_button" => "🈲", "japanese_acceptable_button" => "🉑", "japanese_application_button" => "🈸", + "japanese_passing_grade_button" => "🈴", "japanese_vacancy_button" => "🈳", "japanese_congratulations_button" => "㊗️", "japanese_secret_button" => "㊙️", + "japanese_open_for_business_button" => "🈺", "japanese_no_vacancy_button" => "🈵", "red_circle" => "🔴", "orange_circle" => "🟠", + "yellow_circle" => "🟡", "green_circle" => "🟢", "blue_circle" => "🔵", "purple_circle" => "🟣", + "brown_circle" => "🟤", "black_circle" => "⚫", "white_circle" => "⚪", "red_square" => "🟥", + "orange_square" => "🟧", "yellow_square" => "🟨", "green_square" => "🟩", "blue_square" => "🟦", + "purple_square" => "🟪", "brown_square" => "🟫", "black_large_square" => "⬛", "white_large_square" => "⬜", + "black_medium_square" => "◼️", "white_medium_square" => "◻️", "black_medium_small_square" => "◾", "white_medium_small_square" => "◽", + "black_small_square" => "▪️", "white_small_square" => "▫️", "large_orange_diamond" => "🔶", "large_blue_diamond" => "🔷", + "small_orange_diamond" => "🔸", "small_blue_diamond" => "🔹", "red_triangle_pointed_up" => "🔺", "red_triangle_pointed_down" => "🔻", + "diamond_with_a_dot" => "💠", "radio_button" => "🔘", "white_square_button" => "🔳", "black_square_button" => "🔲", + "chequered_flag" => "🏁", "triangular_flag" => "🚩", "crossed_flags" => "🎌", "black_flag" => "🏴", + "white_flag" => "🏳️", "rainbow_flag" => "🏳️‍🌈", "transgender_flag" => "🏳️‍⚧️", "pirate_flag" => "🏴‍☠️", + "flag_ascension_island" => "🇦🇨", "flag_andorra" => "🇦🇩", "flag_united_arab_emirates" => "🇦🇪", "flag_afghanistan" => "🇦🇫", + "flag_antigua_barbuda" => "🇦🇬", "flag_anguilla" => "🇦🇮", "flag_albania" => "🇦🇱", "flag_armenia" => "🇦🇲", + "flag_angola" => "🇦🇴", "flag_antarctica" => "🇦🇶", "flag_argentina" => "🇦🇷", "flag_american_samoa" => "🇦🇸", + "flag_austria" => "🇦🇹", "flag_australia" => "🇦🇺", "flag_aruba" => "🇦🇼", "flag_aland_islands" => "🇦🇽", + "flag_azerbaijan" => "🇦🇿", "flag_bosnia_herzegovina" => "🇧🇦", "flag_barbados" => "🇧🇧", "flag_bangladesh" => "🇧🇩", + "flag_belgium" => "🇧🇪", "flag_burkina_faso" => "🇧🇫", "flag_bulgaria" => "🇧🇬", "flag_bahrain" => "🇧🇭", + "flag_burundi" => "🇧🇮", "flag_benin" => "🇧🇯", "flag_st_barthelemy" => "🇧🇱", "flag_bermuda" => "🇧🇲", + "flag_brunei" => "🇧🇳", "flag_bolivia" => "🇧🇴", "flag_caribbean_netherlands" => "🇧🇶", "flag_brazil" => "🇧🇷", + "flag_bahamas" => "🇧🇸", "flag_bhutan" => "🇧🇹", "flag_bouvet_island" => "🇧🇻", "flag_botswana" => "🇧🇼", + "flag_belarus" => "🇧🇾", "flag_belize" => "🇧🇿", "flag_canada" => "🇨🇦", "flag_cocos_islands" => "🇨🇨", + "flag_congo_kinshasa" => "🇨🇩", "flag_central_african_republic" => "🇨🇫", "flag_congo_brazzaville" => "🇨🇬", "flag_switzerland" => "🇨🇭", + "flag_cote_d_ivoire" => "🇨🇮", "flag_cook_islands" => "🇨🇰", "flag_chile" => "🇨🇱", "flag_cameroon" => "🇨🇲", + "flag_china" => "🇨🇳", "flag_colombia" => "🇨🇴", "flag_clipperton_island" => "🇨🇵", "flag_sark" => "🇨🇶", + "flag_costa_rica" => "🇨🇷", "flag_cuba" => "🇨🇺", "flag_cape_verde" => "🇨🇻", "flag_curacao" => "🇨🇼", + "flag_christmas_island" => "🇨🇽", "flag_cyprus" => "🇨🇾", "flag_czechia" => "🇨🇿", "flag_germany" => "🇩🇪", + "flag_diego_garcia" => "🇩🇬", "flag_djibouti" => "🇩🇯", "flag_denmark" => "🇩🇰", "flag_dominica" => "🇩🇲", + "flag_dominican_republic" => "🇩🇴", "flag_algeria" => "🇩🇿", "flag_ceuta_melilla" => "🇪🇦", "flag_ecuador" => "🇪🇨", + "flag_estonia" => "🇪🇪", "flag_egypt" => "🇪🇬", "flag_western_sahara" => "🇪🇭", "flag_eritrea" => "🇪🇷", + "flag_spain" => "🇪🇸", "flag_ethiopia" => "🇪🇹", "flag_european_union" => "🇪🇺", "flag_finland" => "🇫🇮", + "flag_fiji" => "🇫🇯", "flag_falkland_islands" => "🇫🇰", "flag_micronesia" => "🇫🇲", "flag_faroe_islands" => "🇫🇴", + "flag_france" => "🇫🇷", "flag_gabon" => "🇬🇦", "flag_united_kingdom" => "🇬🇧", "flag_grenada" => "🇬🇩", + "flag_georgia" => "🇬🇪", "flag_french_guiana" => "🇬🇫", "flag_guernsey" => "🇬🇬", "flag_ghana" => "🇬🇭", + "flag_gibraltar" => "🇬🇮", "flag_greenland" => "🇬🇱", "flag_gambia" => "🇬🇲", "flag_guinea" => "🇬🇳", + "flag_guadeloupe" => "🇬🇵", "flag_equatorial_guinea" => "🇬🇶", "flag_greece" => "🇬🇷", "flag_south_georgia_south_sandwich_islands" => "🇬🇸", + "flag_guatemala" => "🇬🇹", "flag_guam" => "🇬🇺", "flag_guinea_bissau" => "🇬🇼", "flag_guyana" => "🇬🇾", + "flag_hong_kong_sar_china" => "🇭🇰", "flag_heard_mcdonald_islands" => "🇭🇲", "flag_honduras" => "🇭🇳", "flag_croatia" => "🇭🇷", + "flag_haiti" => "🇭🇹", "flag_hungary" => "🇭🇺", "flag_canary_islands" => "🇮🇨", "flag_indonesia" => "🇮🇩", + "flag_ireland" => "🇮🇪", "flag_israel" => "🇮🇱", "flag_isle_of_man" => "🇮🇲", "flag_india" => "🇮🇳", + "flag_british_indian_ocean_territory" => "🇮🇴", "flag_iraq" => "🇮🇶", "flag_iran" => "🇮🇷", "flag_iceland" => "🇮🇸", + "flag_italy" => "🇮🇹", "flag_jersey" => "🇯🇪", "flag_jamaica" => "🇯🇲", "flag_jordan" => "🇯🇴", + "flag_japan" => "🇯🇵", "flag_kenya" => "🇰🇪", "flag_kyrgyzstan" => "🇰🇬", "flag_cambodia" => "🇰🇭", + "flag_kiribati" => "🇰🇮", "flag_comoros" => "🇰🇲", "flag_st_kitts_nevis" => "🇰🇳", "flag_north_korea" => "🇰🇵", + "flag_south_korea" => "🇰🇷", "flag_kuwait" => "🇰🇼", "flag_cayman_islands" => "🇰🇾", "flag_kazakhstan" => "🇰🇿", + "flag_laos" => "🇱🇦", "flag_lebanon" => "🇱🇧", "flag_st_lucia" => "🇱🇨", "flag_liechtenstein" => "🇱🇮", + "flag_sri_lanka" => "🇱🇰", "flag_liberia" => "🇱🇷", "flag_lesotho" => "🇱🇸", "flag_lithuania" => "🇱🇹", + "flag_luxembourg" => "🇱🇺", "flag_latvia" => "🇱🇻", "flag_libya" => "🇱🇾", "flag_morocco" => "🇲🇦", + "flag_monaco" => "🇲🇨", "flag_moldova" => "🇲🇩", "flag_montenegro" => "🇲🇪", "flag_st_martin" => "🇲🇫", + "flag_madagascar" => "🇲🇬", "flag_marshall_islands" => "🇲🇭", "flag_north_macedonia" => "🇲🇰", "flag_mali" => "🇲🇱", + "flag_myanmar" => "🇲🇲", "flag_mongolia" => "🇲🇳", "flag_macao_sar_china" => "🇲🇴", "flag_northern_mariana_islands" => "🇲🇵", + "flag_martinique" => "🇲🇶", "flag_mauritania" => "🇲🇷", "flag_montserrat" => "🇲🇸", "flag_malta" => "🇲🇹", + "flag_mauritius" => "🇲🇺", "flag_maldives" => "🇲🇻", "flag_malawi" => "🇲🇼", "flag_mexico" => "🇲🇽", + "flag_malaysia" => "🇲🇾", "flag_mozambique" => "🇲🇿", "flag_namibia" => "🇳🇦", "flag_new_caledonia" => "🇳🇨", + "flag_niger" => "🇳🇪", "flag_norfolk_island" => "🇳🇫", "flag_nigeria" => "🇳🇬", "flag_nicaragua" => "🇳🇮", + "flag_netherlands" => "🇳🇱", "flag_norway" => "🇳🇴", "flag_nepal" => "🇳🇵", "flag_nauru" => "🇳🇷", + "flag_niue" => "🇳🇺", "flag_new_zealand" => "🇳🇿", "flag_oman" => "🇴🇲", "flag_panama" => "🇵🇦", + "flag_peru" => "🇵🇪", "flag_french_polynesia" => "🇵🇫", "flag_papua_new_guinea" => "🇵🇬", "flag_philippines" => "🇵🇭", + "flag_pakistan" => "🇵🇰", "flag_poland" => "🇵🇱", "flag_st_pierre_miquelon" => "🇵🇲", "flag_pitcairn_islands" => "🇵🇳", + "flag_puerto_rico" => "🇵🇷", "flag_palestinian_territories" => "🇵🇸", "flag_portugal" => "🇵🇹", "flag_palau" => "🇵🇼", + "flag_paraguay" => "🇵🇾", "flag_qatar" => "🇶🇦", "flag_reunion" => "🇷🇪", "flag_romania" => "🇷🇴", + "flag_serbia" => "🇷🇸", "flag_russia" => "🇷🇺", "flag_rwanda" => "🇷🇼", "flag_saudi_arabia" => "🇸🇦", + "flag_solomon_islands" => "🇸🇧", "flag_seychelles" => "🇸🇨", "flag_sudan" => "🇸🇩", "flag_sweden" => "🇸🇪", + "flag_singapore" => "🇸🇬", "flag_st_helena" => "🇸🇭", "flag_slovenia" => "🇸🇮", "flag_svalbard_jan_mayen" => "🇸🇯", + "flag_slovakia" => "🇸🇰", "flag_sierra_leone" => "🇸🇱", "flag_san_marino" => "🇸🇲", "flag_senegal" => "🇸🇳", + "flag_somalia" => "🇸🇴", "flag_suriname" => "🇸🇷", "flag_south_sudan" => "🇸🇸", "flag_sao_tome_principe" => "🇸🇹", + "flag_el_salvador" => "🇸🇻", "flag_sint_maarten" => "🇸🇽", "flag_syria" => "🇸🇾", "flag_eswatini" => "🇸🇿", + "flag_tristan_da_cunha" => "🇹🇦", "flag_turks_caicos_islands" => "🇹🇨", "flag_chad" => "🇹🇩", "flag_french_southern_territories" => "🇹🇫", + "flag_togo" => "🇹🇬", "flag_thailand" => "🇹🇭", "flag_tajikistan" => "🇹🇯", "flag_tokelau" => "🇹🇰", + "flag_timor_leste" => "🇹🇱", "flag_turkmenistan" => "🇹🇲", "flag_tunisia" => "🇹🇳", "flag_tonga" => "🇹🇴", + "flag_turkiye" => "🇹🇷", "flag_trinidad_tobago" => "🇹🇹", "flag_tuvalu" => "🇹🇻", "flag_taiwan" => "🇹🇼", + "flag_tanzania" => "🇹🇿", "flag_ukraine" => "🇺🇦", "flag_uganda" => "🇺🇬", "flag_u_s_outlying_islands" => "🇺🇲", + "flag_united_nations" => "🇺🇳", "flag_united_states" => "🇺🇸", "flag_uruguay" => "🇺🇾", "flag_uzbekistan" => "🇺🇿", + "flag_vatican_city" => "🇻🇦", "flag_st_vincent_grenadines" => "🇻🇨", "flag_venezuela" => "🇻🇪", "flag_british_virgin_islands" => "🇻🇬", + "flag_u_s_virgin_islands" => "🇻🇮", "flag_vietnam" => "🇻🇳", "flag_vanuatu" => "🇻🇺", "flag_wallis_futuna" => "🇼🇫", + "flag_samoa" => "🇼🇸", "flag_kosovo" => "🇽🇰", "flag_yemen" => "🇾🇪", "flag_mayotte" => "🇾🇹", + "flag_south_africa" => "🇿🇦", "flag_zambia" => "🇿🇲", "flag_zimbabwe" => "🇿🇼", "flag_england" => "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + "flag_scotland" => "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "flag_wales" => "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + ]; + + + // Match the emoji code pattern (e.g., `:smile:`) only if it's standalone and not embedded in a word + if (preg_match('/(?<=\s|^):([a-zA-Z0-9_]+):(?=\s|$)/', $Excerpt['text'], $matches) && preg_match('/^(\s|)$/', $Excerpt['before'])) { + $emojiCode = $matches[1]; // Extract the emoji code without colons + + // Check if the emoji code exists in the map + if (isset($emojiMap[$emojiCode])) { + return [ + 'extent' => strlen($matches[0]), // Length of the matched emoji code including colons + 'element' => [ + 'text' => $emojiMap[$emojiCode], // Replace emoji code with corresponding emoji + ], + ]; + } + } + + // If no emoji code matches, return null + return null; + } + + + // Block types + // ------------------------------------------------------------------------- + + /** + * Parses attribute data for headings. + * + * Handles parsing of attribute data for headings if the feature is enabled. + * + * @param string $attributeString The attribute string to be parsed. + * @return array The parsed attributes or an empty array if not applicable. + * @since 0.1.0 + * + */ + protected function parseAttributeData($attributeString) { + // Check if special attributes for headings are enabled + if ($this->config()->get('headings.special_attributes')) { + return parent::parseAttributeData($attributeString); // Delegate to parent class + } + + return []; // Return an empty array if the feature is disabled + } + + /** + * Handles the parsing of footnote blocks. + * + * @param array $Line The line to be processed as a footnote. + * @return mixed The parsed footnote block if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockFootnote($Line) { + // Check if footnotes are enabled + if ($this->config()->get('footnotes')) { + return parent::blockFootnote($Line); // Delegate to parent class + } + } + + /** + * Handles the parsing of definition list blocks. + * + * @param array $Line The current line to be processed. + * @param array $Block The current block context. + * @return mixed The parsed definition list block if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockDefinitionList($Line, $Block) { + // Check if definition lists are enabled + if ($this->config()->get('definition_lists')) { + return parent::blockDefinitionList($Line, $Block); // Delegate to parent class + } + } + + /** + * Handles the parsing of code blocks. + * + * @param array $Line The current line to be processed. + * @param array|null $Block The current block context. + * @return mixed The parsed code block if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockCode($Line, $Block = null) { + // Check if code blocks are enabled + if ($this->config()->get('code') && $this->config()->get('code.blocks')) { + return parent::blockCode($Line, $Block); // Delegate to parent class + } + } + + /** + * Handles the parsing of HTML comment blocks. + * + * @param array $Line The current line to be processed as a comment. + * @return mixed The parsed comment block if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockComment($Line) { + // Check if HTML comments are enabled + if ($this->config()->get('comments')) { + return parent::blockComment($Line); // Delegate to parent class + } + } + + /** + * Handles the parsing of list blocks. + * + * @param array $Line The current line to be processed. + * @param array|null $CurrentBlock The current block context. + * @return mixed The parsed list block if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockList($Line, ?array $CurrentBlock = null) { + // Check if lists are enabled + if ($this->config()->get('lists')) { + return parent::blockList($Line, $CurrentBlock); // Delegate to parent class + } + } + + /** + * Handles the parsing of block quote elements. + * + * @param array $Line The current line to be processed as a block quote. + * @return mixed The parsed block quote if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockQuote($Line) { + // Check if block quotes are enabled + if ($this->config()->get('quotes')) { + return parent::blockQuote($Line); // Delegate to parent class + } + } + + /** + * Handles the parsing of horizontal rule blocks. + * + * @param array $Line The current line to be processed. + * @return mixed The parsed horizontal rule if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockRule($Line) { + // Check if thematic breaks (horizontal rules) are enabled + if ($this->config()->get('thematic_breaks')) { + return parent::blockRule($Line); // Delegate to parent class + } + } + + /** + * Handles the parsing of raw HTML markup blocks. + * + * @param array $Line The current line to be processed as raw HTML. + * @return mixed The parsed HTML block if allowed, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockMarkup($Line) { + // Check if raw HTML is allowed + if ($this->config()->get('allow_raw_html')) { + return parent::blockMarkup($Line); // Delegate to parent class + } + } + + /** + * Handles the parsing of reference blocks. + * + * @param array $Line The current line to be processed as a reference. + * @return mixed The parsed reference block if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockReference($Line) { + // Check if references are enabled + if ($this->config()->get('references')) { + return parent::blockReference($Line); // Delegate to parent class + } + } + + /** + * Handles the parsing of table blocks. + * + * @param array $Line The current line to be processed. + * @param array|null $Block The current block context. + * @return mixed The parsed table block if enabled, otherwise nothing. + * @since 0.1.0 + * + */ + protected function blockTable($Line, $Block = null) { + // Check if tables are enabled + if ($this->config()->get('tables')) { + return parent::blockTable($Line, $Block); // Delegate to parent class + } + } + + + /** + * Processes alert blocks within the parsed Markdown text. + * + * This function identifies and processes blocks starting with a specific alert syntax, such as `> [!NOTE]`. + * Alerts are styled based on their type (e.g., Note, Warning, etc.) and formatted as HTML div elements with appropriate classes. + * + * @param array $Line The line being processed for an alert block. + * @return array|null The parsed alert block if matched, otherwise null. + * @since 1.3.0 + * + */ + protected function blockAlert($Line): ?array { + // Check if alerts are enabled in the configuration settings + if (!$this->config()->get('alerts.enabled')) { + return null; // Return null if alert blocks are disabled + } + + // Retrieve the alert types from the config (e.g., 'NOTE', 'WARNING') + $alertTypes = $this->config()->get('alerts.types'); + + // Build the regex pattern dynamically based on the alert types + $alertTypesPattern = implode('|', array_map('strtoupper', $alertTypes)); + + // Create the full regex pattern for matching alert block syntax + $pattern = '/^> \[!('.$alertTypesPattern.')\]/'; + + // Check if the line matches the alert pattern + if (preg_match($pattern, $Line['text'], $matches)) { + $type = strtolower($matches[1]); // Extract the alert type and convert to lowercase + $title = ucfirst($type); // Capitalize the first letter for the alert title + + // Get class name for alerts from the configuration + $class = $this->config()->get('alerts.class'); + + // Build the alert block with appropriate HTML attributes and content + $Block = [ + 'element' => [ + 'name' => 'div', + 'attributes' => [ + 'class' => "{$class} {$class}-{$type}", // Add alert type as a class (e.g., 'alert alert-note') + ], + 'handler' => 'elements', // Use 'elements' because we'll be adding more content elements later + 'text' => [ + [ + 'name' => 'p', + 'attributes' => [ + 'class' => "{$class}-title", // Assign title-specific class for the alert + ], + 'text' => $title, // Set the alert title (e.g., "Note") + ], + ], + ], + ]; + + return $Block; // Return the parsed alert block + } + + return null; // Return null if the line does not match the alert pattern + } + + /** + * Continues processing alert blocks by adding subsequent lines to the current alert block. + * + * @param array $Line The current line being processed. + * @param array $Block The current block being extended. + * @return array|null The updated alert block or null if the continuation is not applicable. + * @since 1.3.0 + * + */ + protected function blockAlertContinue($Line, array $Block) { + // Retrieve the alert types from the config (e.g., 'NOTE', 'WARNING') + $alertTypes = $this->config()->get('alerts.types'); + + // Build the regex pattern dynamically based on the alert types + $alertTypesPattern = implode('|', array_map('strtoupper', $alertTypes)); + + // Create the full regex pattern for identifying new alert blocks + $pattern = '/^> \[!('.$alertTypesPattern.')\]/'; + + // If the line matches a new alert block, terminate the current one + if (preg_match($pattern, $Line['text'])) { + return null; // Return null to terminate the current alert block + } + + // Check if the line continues the current alert block with '>' followed by content + if ($Line['text'][0] === '>' && preg_match('/^> ?(.*)/', $Line['text'], $matches)) { + // If the block was interrupted, add an empty paragraph for spacing + if (isset($Block['interrupted'])) { + $Block['element']['text'][] = ['text' => '']; + unset($Block['interrupted']); // Reset the interrupted status + } + + // Append the new line content to the current block + $Block['element']['text'][] = [ + 'name' => 'p', + 'text' => $matches[1], // Add the text following the '>' + ]; + + return $Block; // Return the updated block + } + + // If the line does not start with '>' and the block is not interrupted, append it + if (!isset($Block['interrupted'])) { + $Block['element']['text'][] = [ + 'name' => 'p', + 'text' => $Line['text'], // Add the text directly to the alert block + ]; + + return $Block; // Return the updated block + } + + return null; // Return null if the continuation conditions are not met + } + + /** + * Completes the alert block. + * + * @param array $Block The current block being finalized. + * @return array The completed alert block. + * @since 1.3.0 + * + */ + protected function blockAlertComplete($Block) { + return $Block; // Finalize and return the alert block + } + + + /** + * Processes block-level math notation. + * + * This function identifies and processes blocks of text surrounded by specific math delimiters (e.g., `$$` or `\\[ ... \\]`) + * to be formatted as math elements. + * + * @param array $Line The line being processed for a math block. + * @return array|null The parsed math block if matched, otherwise null. + * @since 1.1.2 + * + */ + protected function blockMathNotation($Line) { + // Check if math notation block-level parsing is enabled in the configuration settings + if (!$this->config()->get('math') || !$this->config()->get('math.block')) { + return null; // Return null if math block parsing is disabled + } + + // Iterate over each configured math block delimiter (e.g., `$$`, `\\[`) + foreach ($this->config()->get('math.block.delimiters') as $config) { + + // Escape the math delimiters for regex usage + $leftMarker = preg_quote($config['left'], '/'); + $rightMarker = preg_quote($config['right'], '/'); + + // Build the regex pattern to match the opening delimiter, content, and optional closing delimiter + $regex = '/^(? [ + 'text' => $matches[2], // Extract and store the math content between the delimiters + ], + 'start' => $config['left'], // Store the start marker (e.g., `$$`) + 'end' => $config['right'], // Store the end marker (e.g., `$$`) + ]; + } + } + + return null; // Return null if the line does not match any configured math block pattern + } + + /** + * Continues processing block-level math notation by adding subsequent lines. + * + * This function handles the continuation of a math block until the closing delimiter is found. + * + * @param array $Line The current line being processed. + * @param array $Block The current math block being extended. + * @return array|null The updated math block or null if the continuation is not applicable. + * @since 1.1.2 + * + */ + protected function blockMathNotationContinue($Line, $Block) { + // If the math block is already complete, return null + if (isset($Block['complete'])) { + return null; + } + + // Handle interrupted lines in the math block by adding newlines + if (isset($Block['interrupted'])) { + // Convert the 'interrupted' flag to an integer to determine the number of newlines + $Block['interrupted'] = (int)$Block['interrupted']; + + // Append the appropriate number of newlines to maintain line breaks + $Block['element']['text'] .= str_repeat("\n", $Block['interrupted']); + unset($Block['interrupted']); // Reset the interrupted flag + } + + // Double escape the right marker to properly build the regex pattern for closing delimiter + $rightMarker = preg_quote($Block['end'], '/'); + $regex = '/^(?config()->get('code') || !$this->config()->get('code.blocks')) { + return null; // Return null if code block parsing is disabled + } + + // Use the parent class to parse the fenced code block + $Block = parent::blockFencedCode($Line); + $marker = $Line['text'][0]; // Identify the marker character (e.g., backticks) + $openerLength = strspn($Line['text'], $marker); // Determine the length of the opening markers + + // Extract the language identifier from the fenced code line + $parts = explode(' ', trim(substr($Line['text'], $openerLength)), 2); + $language = strtolower($parts[0]); // Convert the language identifier to lowercase + + // Check if diagram support is enabled in the configuration + if (!$this->config()->get('diagrams')) { + return $Block; // Return the standard code block if diagrams are disabled + } + + // Define custom handlers for specific code block extensions like Mermaid and Chart.js + $extensions = [ + 'mermaid' => ['div', 'mermaid'], // Mermaid diagrams rendered inside a
with class "mermaid" + 'chart' => ['canvas', 'chartjs'], // Chart.js diagrams rendered inside a with class "chartjs" + // Additional languages can be added here as needed + ]; + + // If the specified language matches one of the configured extensions, customize the element + if (isset($extensions[$language])) { + [$elementName, $class] = $extensions[$language]; // Extract the element name and class for the language + + // Return different structures depending on the legacy mode setting + if (!$this->legacyMode) { + // Structure for version 1.8 or newer + return [ + 'char' => $marker, // Store the marker character + 'openerLength' => $openerLength, // Store the length of the opener + 'element' => [ + 'name' => $elementName, // Set the element name (e.g., 'div', 'canvas') + 'element' => [ + 'text' => '', // Placeholder for content + ], + 'attributes' => [ + 'class' => $class, // Add the class for styling (e.g., 'mermaid', 'chartjs') + ], + ], + ]; + } else { + // Structure for version 1.7 or older + return [ + 'char' => $marker, // Store the marker character + 'openerLength' => $openerLength, // Store the length of the opener + 'element' => [ + 'name' => $elementName, // Set the element name (e.g., 'div', 'canvas') + 'handler' => 'element', // Handler type for processing elements + 'text' => [ + 'text' => '', // Placeholder for content + ], + 'attributes' => [ + 'class' => $class, // Add the class for styling (e.g., 'mermaid', 'chartjs') + ], + ], + ]; + } + } + + // Return the standard code block if no special handling is needed + return $Block; + } + + + /** + * Processes list items, including handling task list syntax for checkboxes. + * + * This function processes list items in Markdown and handles special task list syntax (e.g., `- [x]` or `- [ ]`). + * It converts list items into appropriate HTML markup, rendering checkboxes when task lists are enabled. + * The function also maintains compatibility with older parsing modes. + * + * @param array $lines The lines that make up the list item being processed. + * @return mixed The parsed list item markup, either as a string for legacy mode or as an array of elements. + * @since 0.1.0 + * + */ + protected function li($lines) { + // Check if task lists are enabled in the configuration settings + if (!$this->config()->get('lists.tasks')) { + return parent::li($lines); // Return the default list item if task lists are not enabled + } + + // Handling for legacy mode (older versions of the parser) + if ($this->legacyMode) { + // Generate markup for the list item lines + $markup = $this->lines($lines); + + // Get first 4 characters of the generated markup to check for a task checkbox + $firstFourChars = substr($markup, 4, 4); + + // Check if the list item starts with a checkbox (e.g., `[x]` or `[ ]`) + if (preg_match('/^\[[x ]\]/i', $firstFourChars, $matches)) { + // Check if the checkbox is checked (`[x]`) or unchecked (`[ ]`) + if (strtolower($matches[0]) === '[x]') { + // Replace the checkbox marker with an actual checked input element + $markup = substr_replace($markup, '', 4, 4); + } else { + // Replace the checkbox marker with an unchecked input element + $markup = substr_replace($markup, '', 4, 4); + } + } + + // Trim the markup and handle paragraph tags to format correctly + $trimmedMarkup = trim($markup); + if (!in_array('', $lines) && substr($trimmedMarkup, 0, 3) === '

') { + $markup = $trimmedMarkup; + $markup = substr($markup, 3); // Remove opening paragraph tag + + $position = strpos($markup, "

"); + $markup = substr_replace($markup, '', $position, 4); // Remove closing paragraph tag + } + + return $markup; // Return the final markup for the list item + } else { + // Handling for the newer version of the parser + $Elements = $this->linesElements($lines); + + // Extract the text of the first element to check for a task list checkbox + $text = $Elements[0]['handler']['argument']; + $firstFourChars = substr($text, 0, 4); + + // Check if the list item starts with a checkbox (e.g., `[x]` or `[ ]`) + if (preg_match('/^\[[x ]\]/i', $firstFourChars, $matches)) { + // Remove the checkbox marker from the beginning of the text + $Elements[0]['handler']['argument'] = substr_replace($text, '', 0, 4); + + // Set the appropriate attributes based on whether the checkbox is checked or unchecked + if (strtolower($matches[0]) === '[x]') { + $Elements[0]['attributes'] = [ + 'checked' => 'checked', + 'type' => 'checkbox', + 'disabled' => 'disabled', + ]; + } else { + $Elements[0]['attributes'] = [ + 'type' => 'checkbox', + 'disabled' => 'disabled', + ]; + } + + // Set the element type to 'input' for the checkbox + $Elements[0]['name'] = 'input'; + } + + // Remove unnecessary paragraph tags for the list item if not interrupted + if (!in_array('', $lines) + && isset($Elements[0]) && isset($Elements[0]['name']) + && $Elements[0]['name'] === 'p' + ) { + unset($Elements[0]['name']); // Remove paragraph wrapper + } + + return $Elements; // Return the final array of elements for the list item + } + } + + + /** + * Processes ATX-style headers (e.g., `# Header Text`). + * + * This function processes ATX-style headers, checks if the heading levels are allowed, generates an anchor ID for the + * header, and adds it to the Table of Contents (TOC) if applicable. + * + * @param array $Line The line being processed to determine if it is a header. + * @return array|null The parsed header block with added attributes or null if the header is not allowed. + * @since 0.1.0 + * + */ + protected function blockHeader($Line) { + // Check if headings are enabled in the configuration settings + if (!$this->config()->get('headings')) { + return null; // Return null if headings are disabled + } + + // Use the parent class to parse the header block + $Block = parent::blockHeader($Line); + + if (!empty($Block)) { + // Extract the text and level of the header + $text = $Block['element']['text'] ?? $Block['element']['handler']['argument'] ?? ''; + $level = $Block['element']['name']; + + // Check if the header level is allowed (e.g., h1, h2, etc.) + if (!in_array($level, $this->config()->get('headings.allowed_levels'))) { + return null; // Return null if the heading level is not allowed + } + + // Generate an anchor ID for the header element + // If an ID attribute is not set, use the text to create the ID + $id = $Block['element']['attributes']['id'] ?? $text; + $id = $this->createAnchorID($id); + + // Set the 'id' attribute for the header element + $Block['element']['attributes'] = ['id' => $id]; + + // Check if the heading level should be included in the Table of Contents (TOC) + if (!in_array($level, $this->config()->get('toc.levels'))) { + return $Block; // Return the block if it should not be part of the TOC + } + + // Add the heading to the Table of Contents + $this->setContentsList(['text' => $text, 'id' => $id, 'level' => $level]); + + return $Block; // Return the modified header block + } + } + + /** + * Processes Setext-style headers (e.g., `Header Text` followed by `===` or `---`). + * + * This function processes Setext-style headers, checks if the heading levels are allowed, generates an anchor ID for the + * header, and adds it to the Table of Contents (TOC) if applicable. + * + * @param array $Line The line being processed for a Setext header. + * @param array|null $Block The existing block context (if any). + * @return array|null The parsed Setext header block with added attributes or null if the header is not allowed. + * @since 0.1.0 + * + */ + protected function blockSetextHeader($Line, $Block = null) { + // Check if headings are enabled in the configuration settings + if (!$this->config()->get('headings')) { + return null; // Return null if headings are disabled + } + + // Use the parent class to parse the Setext header block + $Block = parent::blockSetextHeader($Line, $Block); + + if (!empty($Block)) { + // Extract the text and level of the header + $text = $Block['element']['text'] ?? $Block['element']['handler']['argument'] ?? ''; + $level = $Block['element']['name']; + + // Check if the header level is allowed (e.g., h1, h2, etc.) + if (!in_array($level, $this->config()->get('headings.allowed_levels'))) { + return null; // Return null if the heading level is not allowed + } + + // Generate an anchor ID for the header element + // If an ID attribute is not set, use the text to create the ID + $id = $Block['element']['attributes']['id'] ?? $text; + $id = $this->createAnchorID($id); + + // Set the 'id' attribute for the header element + $Block['element']['attributes'] = ['id' => $id]; + + // Check if the heading level should be included in the Table of Contents (TOC) + if (!in_array($level, $this->config()->get('toc.levels'))) { + return $Block; // Return the block if it should not be part of the TOC + } + + // Add the heading to the Table of Contents + $this->setContentsList(['text' => $text, 'id' => $id, 'level' => $level]); + + return $Block; // Return the modified Setext header block + } + } + + + /** + * Processes abbreviation blocks. + * + * This function handles the parsing of abbreviation definitions. It checks if abbreviations are enabled + * in the configuration and whether custom abbreviations are allowed. If custom abbreviations are allowed, + * it delegates the parsing to the parent class method. + * + * @param array $Line The line being processed to determine if it defines an abbreviation. + * @return array|null The parsed abbreviation block or null if abbreviations are disabled or custom abbreviations are not allowed. + * @since 0.1.0 + * + */ + protected function blockAbbreviation($Line) { + // Check if abbreviation support is enabled in the configuration settings + if ($this->config()->get('abbreviations')) { + + // If custom abbreviations are allowed, delegate to the parent class to handle parsing + if ($this->config()->get('abbreviations.allow_custom')) { + return parent::blockAbbreviation($Line); // Parse custom abbreviation using parent method + } + + // If custom abbreviations are not allowed, return null to prevent processing + return null; + } + + // Return null if abbreviations are completely disabled in the configuration + return null; + } + + + /** + * Completes the processing of table blocks. + * + * This function processes table blocks after the initial parsing to handle special features such as column spans + * and row spans. It processes each cell in the table, merging cells where indicated by specific characters + * (e.g., '>' for colspan and '^' for rowspan). The implementation handles both legacy and modern parsing modes. + * + * @param array $block The parsed table block to be processed further. + * @return array The completed and modified table block. + * @since 1.0.1 + * + */ + protected function blockTableComplete(array $block): array { + // Check if table spanning (colspan and rowspan) is enabled + if (!$this->config()->get('tables.tablespan')) { + return $block; // Return the original block if spanning is not enabled + } + + // Reference to header elements depending on legacy mode or newer version + if ($this->legacyMode === true) { + // Version 1.7 + $headerElements = &$block['element']['text'][0]['text'][0]['text']; + } else { + // Version 1.8 + $headerElements = &$block['element']['elements'][0]['elements'][0]['elements']; + } + + // Process colspan in header elements + for ($index = count($headerElements) - 1; $index >= 0; --$index) { + $colspan = 1; + $headerElement = &$headerElements[$index]; + + if ($this->legacyMode === true) { + // Version 1.7 + while ($index && $headerElements[$index - 1]['text'] === '>') { + $colspan++; + $PreviousHeaderElement = &$headerElements[--$index]; + $PreviousHeaderElement['merged'] = true; + if (isset($PreviousHeaderElement['attributes'])) { + $headerElement['attributes'] = $PreviousHeaderElement['attributes']; + } + } + } else { + // Version 1.8 + while ($index && '>' === $headerElements[$index - 1]['handler']['argument']) { + $colspan++; + $PreviousHeaderElement = &$headerElements[--$index]; + $PreviousHeaderElement['merged'] = true; + if (isset($PreviousHeaderElement['attributes'])) { + $headerElement['attributes'] = $PreviousHeaderElement['attributes']; + } + } + } + + // Assign colspan attribute if colspan is greater than 1 + if ($colspan > 1) { + if (!isset($headerElement['attributes'])) { + $headerElement['attributes'] = []; + } + $headerElement['attributes']['colspan'] = $colspan; + } + } + + // Remove merged header elements + for ($index = count($headerElements) - 1; $index >= 0; --$index) { + if (isset($headerElements[$index]['merged'])) { + array_splice($headerElements, $index, 1); + } + } + + // Reference to table rows based on legacy or modern mode + if ($this->legacyMode === true) { + // Version 1.7 + $rows = &$block['element']['text'][1]['text']; + } else { + // Version 1.8 + $rows = &$block['element']['elements'][1]['elements']; + } + + // Process colspan for rows + foreach ($rows as &$row) { + if ($this->legacyMode === true) { + // Version 1.7 + $elements = &$row['text']; + } else { + // Version 1.8 + $elements = &$row['elements']; + } + + for ($index = count($elements) - 1; $index >= 0; --$index) { + $colspan = 1; + $element = &$elements[$index]; + + if ($this->legacyMode === true) { + // Version 1.7 + while ($index && $elements[$index - 1]['text'] === '>') { + $colspan++; + $PreviousElement = &$elements[--$index]; + $PreviousElement['merged'] = true; + if (isset($PreviousElement['attributes'])) { + $element['attributes'] = $PreviousElement['attributes']; + } + } + } else { + // Version 1.8 + while ($index && '>' === $elements[$index - 1]['handler']['argument']) { + ++$colspan; + $PreviousElement = &$elements[--$index]; + $PreviousElement['merged'] = true; + if (isset($PreviousElement['attributes'])) { + $element['attributes'] = $PreviousElement['attributes']; + } + } + } + + // Assign colspan attribute if colspan is greater than 1 + if ($colspan > 1) { + if (!isset($element['attributes'])) { + $element['attributes'] = []; + } + $element['attributes']['colspan'] = $colspan; + } + } + } + + // Process rowspan for rows + foreach ($rows as $rowNo => &$row) { + if ($this->legacyMode === true) { + // Version 1.7 + $elements = &$row['text']; + } else { + // Version 1.8 + $elements = &$row['elements']; + } + + foreach ($elements as $index => &$element) { + $rowspan = 1; + + if (isset($element['merged'])) { + continue; // Skip merged elements + } + + if ($this->legacyMode === true) { + // Version 1.7 + while ( + $rowNo + $rowspan < count($rows) && + $index < count($rows[$rowNo + $rowspan]['text']) && + $rows[$rowNo + $rowspan]['text'][$index]['text'] === '^' && + (@$element['attributes']['colspan'] ?: null) === (@$rows[$rowNo + $rowspan]['text'][$index]['attributes']['colspan'] ?: null) + ) { + $rows[$rowNo + $rowspan]['text'][$index]['merged'] = true; + $rowspan++; + } + } else { + // Version 1.8 + while ( + $rowNo + $rowspan < count($rows) && + $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++; + } + } + + // Assign rowspan attribute if rowspan is greater than 1 + if ($rowspan > 1) { + if (!isset($element['attributes'])) { + $element['attributes'] = []; + } + $element['attributes']['rowspan'] = $rowspan; + } + } + } + + // Remove merged elements after processing row spans + foreach ($rows as &$row) { + if ($this->legacyMode === true) { + // Version 1.7 + $elements = &$row['text']; + } else { + // Version 1.8 + $elements = &$row['elements']; + } + + for ($index = count($elements) - 1; $index >= 0; --$index) { + if (isset($elements[$index]['merged'])) { + array_splice($elements, $index, 1); // Remove merged element + } + } + } + + return $block; // Return the completed and modified table block + } + + + // Functions related to Table of Contents + // Modified version of ToC by @KEINOS + // ------------------------------------------------------------------------- + + /** + * Parses the provided text and handles escaping/unescaping of ToC tags. + * + * This function processes the given text, escaping the ToC tags temporarily, + * parsing the Markdown text into HTML, and then unescaping the ToC tags to + * include them in the final output. + * + * @param string $text The input Markdown text to be parsed. + * @return string The parsed HTML text with ToC tags properly handled. + * @since 1.0.0 + * + */ + public function body(string $text): string { + $text = $this->encodeTag($text); // Escapes ToC tag temporarily + $html = parent::text($text); // Parses the markdown text + return $this->decodeTag($html); // Unescapes the ToC tag + } + + /** + * Retrieves the Table of Contents (ToC) in the specified format. + * + * This function returns the ToC either as a formatted string or as a JSON + * string. If an unknown type is provided, an exception is thrown. + * + * @param string $type_return The desired return format: 'string' or 'json'. + * @return string The Table of Contents in the specified format. + * @throws \InvalidArgumentException If an unknown return type is provided. + * @since 1.0.0 + * + */ + public function contentsList(string $type_return = 'string'): string { + switch (strtolower($type_return)) { + case 'string': + return $this->contentsListString ? $this->body($this->contentsListString) : ''; + case 'json': + return json_encode($this->contentsListArray); + default: + $backtrace = debug_backtrace(); + $caller = $backtrace[1] ?? $backtrace[0]; + $errorMessage = "Unknown return type '{$type_return}' given while parsing ToC. Called in ".($caller['file'] ?? 'unknown')." on line ".($caller['line'] ?? 'unknown'); + throw new \InvalidArgumentException($errorMessage); + } + } + + /** + * Sets a callback function for creating anchor IDs for headers. + * + * This allows the user to provide custom logic for generating anchor IDs for + * the headers found in the Markdown content. + * + * @param callable $callback The callback function to generate anchor IDs. + * @return void + * @since 1.2.0 + * + */ + public function setCreateAnchorIDCallback(callable $callback): void { + $this->createAnchorIDCallback = $callback; + } + + /** + * Creates an anchor ID for a given header text. + * + * This function generates a unique anchor ID for a header, allowing for custom + * callbacks to be used for the generation logic. If no callback is provided, + * default logic is used, including transliteration, normalization, and sanitization. + * + * @param string $text The header text for which an anchor ID is generated. + * @return string|null The generated anchor ID or null if auto anchors are disabled. + * @since 1.0.0 + * + */ + protected function createAnchorID(string $text): ?string { + // Check if automatic anchor generation is enabled in the settings + if (!$this->config()->get('headings.auto_anchors')) { + return null; // Return null if auto anchors are disabled + } + + // If a user-defined callback is provided, use it to generate the anchor ID + if (is_callable($this->createAnchorIDCallback)) { + return call_user_func($this->createAnchorIDCallback, $text, $this->config()); + } + + // Default logic for anchor ID creation + + // Convert text to lowercase if configured to do so + if ($this->config()->get('headings.auto_anchors.lowercase')) { + if (extension_loaded('mbstring')) { + $text = mb_strtolower($text); + } else { + $text = strtolower($text); + } + } + + // Apply replacements to the text based on the configuration settings + if ($this->config()->get('headings.auto_anchors.replacements')) { + $text = preg_replace(array_keys($this->config()->get('headings.auto_anchors.replacements')), $this->config()->get('headings.auto_anchors.replacements'), $text); + } + + // Normalize the text (ensure proper encoding) + $text = $this->normalizeString($text); + + // Transliterate text if configured to do so + if ($this->config()->get('headings.auto_anchors.transliterate')) { + $text = $this->transliterate($text); + } + + // Sanitize the text to make it a valid anchor ID + $text = $this->sanitizeAnchor($text); + + // Ensure the generated anchor ID is unique + return $this->uniquifyAnchorID($text); + } + + /** + * Normalizes the given string to UTF-8 encoding. + * + * This function ensures that the given text is properly encoded to UTF-8, using + * `mb_convert_encoding` if available. If `mbstring` is not available, it returns + * the raw string as there is no equivalent alternative. + * + * @param string $text The input string to be normalized. + * @return string The normalized string. + * @since 1.2.0 + * + */ + protected function normalizeString(string $text) { + if (extension_loaded('mbstring')) { + return mb_convert_encoding($text, 'UTF-8', mb_list_encodings()); + } else { + return $text; // Return raw text as there is no good alternative for mb_convert_encoding + } + } + + /** + * Transliterates the given string to ASCII format. + * + * This function attempts to transliterate text to ASCII, making it suitable for + * use in anchor IDs. It uses PHP's `Transliterator` class if available. If not, + * a manual transliteration method is used as a fallback. + * + * @param string $text The text to be transliterated. + * @return string The transliterated text. + * @since 1.2.0 + * + */ + protected function transliterate(string $text): string { + if (class_exists('\Transliterator')) { + $transliterator = \Transliterator::create('Any-Latin; Latin-ASCII;'); + if ($transliterator) { + return $transliterator->transliterate($text); + } + } + + return $this->manualTransliterate($text); // Use manual transliteration if `Transliterator` is not available + } + + + /** + * Manually transliterates a string from various alphabets to ASCII. + * + * This function converts characters from different scripts (Latin, Greek, Cyrillic, etc.) into their ASCII equivalents. + * It uses a predefined character map to replace accented or special characters with simpler ASCII versions. + * + * @param string $text The input text to be transliterated. + * @return string The transliterated ASCII string. + * @since 1.3.0 + * + */ + protected function manualTransliterate(string $text): string { + // Character mapping from different alphabets to their ASCII equivalents + $characterMap = [ + // 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', 'Θ' => 'TH', + 'Ι' => 'I', 'Κ' => 'K', 'Λ' => 'L', 'Μ' => 'M', 'Ν' => 'N', 'Ξ' => 'X', 'Ο' => 'O', 'Π' => 'P', + 'Ρ' => 'R', 'Σ' => 'S', 'Τ' => 'T', 'Υ' => 'Y', 'Φ' => 'F', 'Χ' => 'X', 'Ψ' => 'PS', 'Ω' => 'O', + 'Ά' => 'A', 'Έ' => 'E', 'Ί' => 'I', 'Ό' => 'O', 'Ύ' => 'Y', 'Ή' => 'H', 'Ώ' => 'O', 'Ϊ' => 'I', + 'Ϋ' => 'Y', + 'α' => 'a', 'β' => 'b', 'γ' => 'g', 'δ' => 'd', 'ε' => 'e', 'ζ' => 'z', 'η' => 'h', 'θ' => 'th', + 'ι' => 'i', 'κ' => 'k', 'λ' => 'l', 'μ' => 'm', 'ν' => 'n', 'ξ' => 'x', 'ο' => 'o', 'π' => 'p', + 'ρ' => 'r', 'σ' => 's', 'τ' => 't', 'υ' => 'y', 'φ' => 'f', 'χ' => 'x', 'ψ' => 'ps', 'ω' => 'o', + 'ά' => 'a', 'έ' => 'e', 'ί' => 'i', 'ό' => 'o', 'ύ' => 'y', 'ή' => 'h', 'ώ' => 'o', 'ς' => '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', 'Х' => 'Kh', 'Ц' => 'Ts', + 'Ч' => 'Ch', 'Ш' => 'Sh', 'Щ' => 'Shch', 'Ъ' => 'U', 'Ы' => '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', 'х' => 'kh', 'ц' => 'ts', + 'ч' => 'ch', 'ш' => 'sh', 'щ' => 'shch', 'ъ' => 'u', 'ы' => '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', + ]; + + // Perform the character replacements based on the map + return strtr($text, $characterMap); + } + + /** + * Sanitizes a string to make it suitable for use as an HTML anchor ID. + * + * This function replaces non-alphanumeric characters in the string with a delimiter + * (e.g., hyphen), ensuring the result is suitable as an HTML ID. Consecutive delimiters + * are collapsed into a single delimiter, and leading/trailing delimiters are trimmed. + * + * @param string $text The input text to be sanitized. + * @return string The sanitized string suitable for use as an anchor ID. + * @since 1.2.0 + * + */ + protected function sanitizeAnchor(string $text): string { + // Get the delimiter used to replace non-alphanumeric characters (e.g., '-') + $delimiter = $this->config()->get('headings.auto_anchors.delimiter'); + + // Replace any character that is not a letter or number with the delimiter + $text = preg_replace('/[^\p{L}\p{Nd}]+/u', $delimiter, $text); + + // Collapse consecutive delimiters into a single delimiter + $text = preg_replace('/('.preg_quote($delimiter, '/').'){2,}/', '$1', $text); + + // Trim any leading or trailing delimiters + $text = trim($text, $delimiter); + + return $text; + } + + /** + * Ensures that the generated anchor ID is unique. + * + * This function keeps track of generated anchor IDs to avoid duplicates. If an anchor ID has already been used, + * it appends a unique suffix to it. Blacklisted anchor IDs are also skipped to ensure the final anchor is valid. + * + * @param string $text The base anchor ID text. + * @return string A unique anchor ID. + * @since 1.2.0 + * + */ + protected function uniquifyAnchorID(string $text): string { + // Retrieve the blacklist of forbidden anchor IDs from the configuration + $blacklist = $this->config()->get('headings.auto_anchors.blacklist'); + + // Store the original text to use as the base for creating unique variants + $originalText = $text; + + // Initialize or increment the counter for this specific anchor text + if (!isset($this->anchorRegister[$text])) { + $this->anchorRegister[$text] = 0; + } else { + $this->anchorRegister[$text]++; + } + + // Adjust the anchor ID to ensure it is unique and not in the blacklist + while (true) { + // Generate the potential anchor ID with the count as suffix (if needed) + $potentialId = $originalText.($this->anchorRegister[$text] > 0 ? '-'.$this->anchorRegister[$text] : ''); + + // Check if the potential ID is not blacklisted + if (!in_array($potentialId, $blacklist)) { + break; // The ID is valid and not blacklisted, so we can use it + } + + // Increment the counter to generate the next potential ID + $this->anchorRegister[$text]++; + } + + // If no suffix is required, return the original anchor text + if ($this->anchorRegister[$text] === 0) { + return $originalText; + } + + // Return the unique anchor ID with the appropriate suffix + return $originalText.'-'.$this->anchorRegister[$text]; + } + + + /** + * Decodes the ToC tag by replacing a hashed version with the original tag. + * + * This function looks for the hashed ToC tag within the text and replaces it with the original ToC tag, + * effectively decoding the tag back to its original form. + * + * @param string $text The input text containing the hashed ToC tag. + * @return string The text with the hashed ToC tag replaced by the original tag. + * @since 1.2.0 + * + */ + protected function decodeTag(string $text): string { + $salt = $this->getSalt(); // Retrieve the salt used for hashing + $tag_origin = $this->config()->get('toc.tag'); // Get the original ToC tag + $tag_hashed = hash('sha256', $salt.$tag_origin); // Generate the hashed version of the ToC tag + + // If the hashed tag is not found, return the original text + if (strpos($text, $tag_hashed) === false) { + return $text; + } + + // Replace the hashed tag with the original tag + return str_replace($tag_hashed, $tag_origin, $text); + } + + /** + * Encodes the ToC tag by replacing it with a hashed version. + * + * This function looks for the original ToC tag in the text and replaces it with a hashed version, + * effectively encoding it to avoid conflicts during parsing. + * + * @param string $text The input text containing the ToC tag. + * @return string The text with the original ToC tag replaced by the hashed version. + * @since 1.2.0 + * + */ + protected function encodeTag(string $text): string { + $salt = $this->getSalt(); // Retrieve the salt used for hashing + $tag_origin = $this->config()->get('toc.tag'); // Get the original ToC tag + + // If the original tag is not found, return the original text + if (strpos($text, $tag_origin) === false) { + return $text; + } + + // Generate the hashed version of the ToC tag and replace the original tag + $tag_hashed = hash('sha256', $salt.$tag_origin); + return str_replace($tag_origin, $tag_hashed, $text); + } + + /** + * Fetches plain text from a given input by stripping tags. + * + * This function parses the given text using line formatting, then strips any HTML tags and trims whitespace, + * effectively extracting plain text. + * + * @param string $text The input text to be fetched. + * @return string The plain text version of the input. + * @since 1.0.0 + * + */ + protected function fetchText($text): string { + return trim(strip_tags($this->line($text))); + } + + /** + * Generates or retrieves a salt value for use in hashing. + * + * This function generates a unique salt value based on the current timestamp if it hasn't been set yet. + * The salt is used to create a unique hash for ToC tags, making them harder to predict. + * + * @return string The generated or retrieved salt value. + * @since 1.0.0 + * + */ + protected function getSalt(): string { + static $salt; + if (isset($salt)) { + return $salt; // Return the previously generated salt + } + + // Generate a new salt based on the current timestamp + $salt = hash('md5', (string)time()); + return $salt; + } + + /** + * Adds an entry to the contents list in both array and string formats. + * + * This function stores a representation of the contents as both an array and a formatted string. + * The array format can be used for structured data, while the string format is used for Markdown. + * + * @param array $Content The content entry containing 'text', 'id', and 'level' keys. + * @return void + * @since 1.0.0 + * + */ + protected function setContentsList(array $Content): void { + // Stores content as an array + $this->setContentsListAsArray($Content); + // Stores content as a string in Markdown list format + $this->setContentsListAsString($Content); + } + + /** + * Stores the given content entry in the Table of Contents array. + * + * This function adds the content entry to the `contentsListArray`, which is used to hold a structured + * representation of all ToC entries. + * + * @param array $Content The content entry to be stored. + * @return void + * @since 1.0.0 + * + */ + protected function setContentsListAsArray(array $Content): void { + $this->contentsListArray[] = $Content; // Append content to the contents list array + } + + /** + * Adds the given content entry to the Table of Contents string. + * + * This function creates a formatted Markdown list item for the content and appends it to the + * Table of Contents string, which is used to generate the ToC in Markdown format. + * + * @param array $Content The content entry containing 'text', 'id', and 'level' keys. + * @return void + * @since 1.0.0 + * + */ + protected function setContentsListAsString(array $Content): void { + $text = $this->fetchText($Content['text']); // Fetch the plain text of the content + $id = $Content['id']; // Get the ID of the content + $level = (int)trim($Content['level'], 'h'); // Get the level of the heading and convert to an integer + $link = "[{$text}](#{$id})"; // Create a Markdown link to the heading + + // Set the first heading level if it hasn't been set yet + if ($this->firstHeadLevel === 0) { + $this->firstHeadLevel = $level; + } + + // Calculate the indent level for the list item + $indentLevel = max(1, $level - ($this->firstHeadLevel - 1)); + $indent = str_repeat(' ', $indentLevel); // Create the appropriate indent based on the level + + // Append the formatted list item to the contents list string + $this->contentsListString .= "{$indent}- {$link}".PHP_EOL; + } + + /** + * Parses the given Markdown text and replaces the ToC tag with the generated Table of Contents. + * + * This function calls the `body()` method to parse Markdown, and then replaces the placeholder + * ToC tag with the generated Table of Contents in HTML format. + * + * @param string $text The input Markdown text. + * @return string The parsed HTML text with the ToC embedded. + * @since 0.1.0 + * + */ + public function text($text): string { + $html = $this->body($text); // Parse the Markdown text into HTML + + // If ToC functionality is disabled in the config, return the parsed HTML as is + if (!$this->config()->get('toc')) { + return $html; + } + + // Get the original ToC tag and check if it is in the input text + $tag_origin = $this->config()->get('toc.tag'); + if (strpos($text, $tag_origin) === false) { + return $html; // Return HTML if the ToC tag is not found + } + + // Replace the ToC placeholder with the actual ToC content + $toc_data = $this->contentsList(); + $toc_id = $this->config()->get('toc.id'); + return str_replace("

{$tag_origin}

", "
{$toc_data}
", $html); + } + + /** + * Processes unmarked text, adding predefined abbreviations if configured. + * + * This function extends the parent class's functionality by adding predefined + * abbreviations from the configuration, before processing the unmarked text. + * + * @param string $text The input text to be processed. + * @return string The processed text with abbreviations applied. + * @since 0.1.0 + * + */ + protected function unmarkedText($text) { + // Add predefined abbreviations to the definition data + foreach ($this->config()->get('abbreviations.predefined') as $abbreviation => $description) { + $this->DefinitionData['Abbreviation'][$abbreviation] = $description; + } + + // Call the parent method to handle the rest of the text processing + $text = parent::unmarkedText($text); + + return $text; + } + + + // Settings + // ------------------------------------------------------------------------- + + /** + * Sets a configuration setting (DEPRECATED). + * + * This method sets a configuration setting using the new configuration system. + * It is deprecated and will be removed in future versions. Use `$ParsedownExtended->config()->set()` instead. + * + * @param string $settingName The name of the setting to set. + * @param mixed $value The value to set for the setting. + * @param bool $overwrite Whether to overwrite an existing setting (default: false). + * @return void + * @deprecated 1.3.0 Use ParsedownExtended->config()->set() instead. + * @see ParsedownExtended->config()->set() + * + * @since 1.2.0 + */ + public function setSetting(string $settingName, $value, bool $overwrite = false) { + // Log the use of deprecated method for future reference + $this->deprecated(__METHOD__, '1.3.0', '$ParsedownExtended->config()->set()'); + + // Use the new configuration system to set the value + $this->config()->set($settingName, $value); + } + + /** + * Sets multiple configuration settings at once (DEPRECATED). + * + * This method sets multiple configuration settings using the new configuration system. + * It is deprecated and will be removed in future versions. Use `$ParsedownExtended->config()->set()` instead. + * + * @param array $settings An associative array of settings to set (key-value pairs). + * @return $this + * @see ParsedownExtended->config()->set() + * + * @since 1.2.0 + * @deprecated 1.3.0 Use ParsedownExtended->config()->set() instead. + */ + public function setSettings(array $settings) { + // Log the use of deprecated method for future reference + $this->deprecated(__METHOD__, '1.3.0', '$ParsedownExtended->config()->set()'); + + // Set each individual setting using the existing setSetting method + foreach ($settings as $key => $value) { + $this->setSetting($key, $value); + } + + return $this; + } + + /** + * Checks if a configuration setting is enabled (DEPRECATED). + * + * This method checks if a particular setting is enabled using the new configuration system. + * It is deprecated and will be removed in future versions. Use `$ParsedownExtended->config()->get()` instead. + * + * @param string $keyPath The key path of the setting to check. + * @return mixed The value of the setting (generally a boolean for 'enabled' settings). + * @see ParsedownExtended->config()->get() + * + * @since 1.2.2 + * @deprecated 1.3.0 Use ParsedownExtended->config()->get() instead. + */ + public function isEnabled(string $keyPath) { + // Log the use of deprecated method for future reference + $this->deprecated(__METHOD__, '1.3.0', '$ParsedownExtended->config()->get()'); + + // Use the new configuration system to get the value + return $this->config()->get($keyPath); + } + + /** + * Gets the value of a configuration setting (DEPRECATED). + * + * This method retrieves a setting using the new configuration system. + * It is deprecated and will be removed in future versions. Use `$ParsedownExtended->config()->get()` instead. + * + * @param string $key The key of the setting to retrieve. + * @return mixed The value of the specified setting. + * @see ParsedownExtended->config()->get() + * + * @since 1.2.0 + * @deprecated 1.3.0 Use ParsedownExtended->config()->get() instead. + */ + public function getSetting(string $key) { + // Log the use of deprecated method for future reference + $this->deprecated(__METHOD__, '1.3.0', '$ParsedownExtended->config()->get()'); + + // Use the new configuration system to get the value + return $this->config()->get($key); + } + + /** + * Gets all configuration settings (DEPRECATED). + * + * This method retrieves all settings. + * It is deprecated and will be removed in future versions. Use `$ParsedownExtended->config()->get()` instead. + * + * @return array An associative array of all settings. + * @deprecated 1.3.0 Use ParsedownExtended->config()->get() instead. + * @see ParsedownExtended->config()->get() + * + * @since 1.2.0 + */ + public function getSettings() { + // Log the use of deprecated method for future reference + $this->deprecated(__METHOD__, '1.3.0', '$ParsedownExtended->config()->get()'); + + // Return the current settings + return $this->settings; + } + + + // Helper functions + // ------------------------------------------------------------------------- + + /** + * Registers an inline type marker with a corresponding handler function. + * + * This function ensures that a given marker is registered for inline parsing, associating it with + * a handler function that will handle the inline behavior for that marker. + * + * @param mixed $markers One or more markers to register (can be a string or an array). + * @param string $funcName The name of the handler function associated with the marker(s). + * @return void + * @since 1.1.2 + * + */ + private function addInlineType($markers, string $funcName): void { + // Ensure $markers is always an array, even if a single marker is passed as a string + $markers = (array)$markers; + + foreach ($markers as $marker) { + // If the marker is not already registered, initialize it + if (!isset($this->InlineTypes[$marker])) { + $this->InlineTypes[$marker] = []; + } + + // Add the marker to the special characters array if it's not already present + if (!in_array($marker, $this->specialCharacters)) { + $this->specialCharacters[] = $marker; + } + + // Add the function name to the beginning of the marker's handlers for priority + array_unshift($this->InlineTypes[$marker], $funcName); + + // Append the marker to the inline marker list + $this->inlineMarkerList .= $marker; + } + } + + /** + * Registers a block type marker with a corresponding handler function. + * + * This function ensures that a given marker is registered for block parsing, associating it with + * a handler function that will handle the block behavior for that marker. + * + * @param mixed $markers One or more markers to register (can be a string or an array). + * @param string $funcName The name of the handler function associated with the marker(s). + * @return void + * @since 1.1.2 + * + */ + private function addBlockType($markers, string $funcName): void { + // Ensure $markers is always an array, even if a single marker is passed as a string + $markers = (array)$markers; + + foreach ($markers as $marker) { + // If the marker is not already registered, initialize it + if (!isset($this->BlockTypes[$marker])) { + $this->BlockTypes[$marker] = []; + } + + // Add the marker to the special characters array if it's not already present + if (!in_array($marker, $this->specialCharacters)) { + $this->specialCharacters[] = $marker; + } + + // Add the function name to the beginning of the marker's handlers for priority + array_unshift($this->BlockTypes[$marker], $funcName); + } + } + + /** + * Warns users about deprecated functions. + * + * This function is used to trigger a deprecation warning when deprecated functions are called. + * It informs the user about the function being deprecated, the version it was deprecated in, + * and suggests an alternative function to use. + * + * @param string $functionName The name of the deprecated function. + * @param string $version The version in which the function was deprecated. + * @param string $alternative (Optional) The name of an alternative function to use. + * @return void + * @since 1.3.0 + * + */ + private function deprecated(string $functionName, string $version, string $alternative = ''): void { + // Get the call stack to determine where this deprecated function was called + $backtrace = debug_backtrace(); + $caller = $backtrace[1] ?? $backtrace[0]; + + // Create the deprecation message with the function name and version + $message = "Function {$functionName} is deprecated as of version {$version} and will be removed in the future. "; + // Append an alternative function suggestion if provided + $message .= $alternative ? "Use {$alternative} instead." : ''; + // Include the file and line number where the deprecated function was called + $message .= " Called in {$caller['file']} on line {$caller['line']}"; + + // Trigger the deprecated warning + trigger_error($message, E_USER_DEPRECATED); + } + + // Configurations Handler + // ------------------------------------------------------------------------- + + /** + * Initialize configuration using a given schema. + * + * This function iterates through the given schema to initialize the default configuration settings. + * It handles nested arrays and array types with nested defaults. + * + * @param array $schema The configuration schema to use for initialization. + * @return array The initialized configuration based on the given schema. + * @since 1.3.0 + * + */ + private function initializeConfig(array $schema) { + $config = []; + foreach ($schema as $key => $definition) { + // Handle array types with nested defaults + if (isset($definition['type'])) { + if ($definition['type'] === 'array' && is_array($definition['default'])) { + $config[$key] = $this->initializeConfig($definition['default']); + } else { + $config[$key] = $definition['default']; + } + } else { + // Recursively initialize nested configurations + if (is_array($definition)) { + $config[$key] = $this->initializeConfig($definition); + } else { + $config[$key] = $definition; + } + } + } + return $config; + } + + /** + * Define the configuration schema. + * + * This function returns a comprehensive configuration schema that defines the type, + * default values, and nested structures for each configuration setting. + * + * @return array The defined configuration schema. + * @since 1.3.0 + * + */ + private function defineConfigSchema(): array { + return [ + 'abbreviations' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'allow_custom' => ['type' => 'boolean', 'default' => true], + 'predefined' => [ + 'type' => 'array', + 'default' => [], + 'item_schema' => [ + 'key_type' => 'string', + 'value_type' => 'string', + ], + ], + ], + 'code' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'blocks' => ['type' => 'boolean', 'default' => true], + 'inline' => ['type' => 'boolean', 'default' => true], + ], + 'comments' => ['type' => 'boolean', 'default' => true], + 'definition_lists' => ['type' => 'boolean', 'default' => true], + 'diagrams' => [ + 'enabled' => ['type' => 'boolean', 'default' => false], + 'chartjs' => ['type' => 'boolean', 'default' => true], + 'mermaid' => ['type' => 'boolean', 'default' => true], + ], + 'emojis' => ['type' => 'boolean', 'default' => true], + 'emphasis' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'bold' => ['type' => 'boolean', 'default' => true], + 'italic' => ['type' => 'boolean', 'default' => true], + 'strikethroughs' => ['type' => 'boolean', 'default' => true], + 'insertions' => ['type' => 'boolean', 'default' => true], + 'subscript' => ['type' => 'boolean', 'default' => false], + 'superscript' => ['type' => 'boolean', 'default' => false], + 'keystrokes' => ['type' => 'boolean', 'default' => true], + 'mark' => ['type' => 'boolean', 'default' => true], + ], + 'footnotes' => ['type' => 'boolean', 'default' => true], + 'headings' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'allowed_levels' => ['type' => 'array', 'default' => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']], + 'auto_anchors' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'delimiter' => ['type' => 'string', 'default' => '-'], + 'lowercase' => ['type' => 'boolean', 'default' => true], + 'replacements' => ['type' => 'array', 'default' => []], + 'transliterate' => ['type' => 'boolean', 'default' => false], + 'blacklist' => ['type' => 'array', 'default' => []], + ], + 'special_attributes' => ['type' => 'boolean', 'default' => true], + ], + 'images' => ['type' => 'boolean', 'default' => true], + 'links' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'email_links' => ['type' => 'boolean', 'default' => true], + 'external_links' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'nofollow' => ['type' => 'boolean', 'default' => true], + 'noopener' => ['type' => 'boolean', 'default' => true], + 'noreferrer' => ['type' => 'boolean', 'default' => true], + 'open_in_new_window' => ['type' => 'boolean', 'default' => true], + 'internal_hosts' => [ + 'type' => 'array', 'default' => [], + 'item_schema' => ['type' => 'string'], + ], + ], + ], + 'lists' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'tasks' => ['type' => 'boolean', 'default' => true], + ], + 'allow_raw_html' => ['type' => 'boolean', 'default' => true], + 'alerts' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'types' => [ + 'type' => 'array', + 'default' => ['note', 'tip', 'important', 'warning', 'caution'], + 'item_schema' => ['type' => 'string'], + ], + 'class' => ['type' => 'string', 'default' => 'markdown-alert'], + ], + 'math' => [ + 'enabled' => ['type' => 'boolean', 'default' => false], + 'inline' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'delimiters' => [ + 'type' => 'array', + 'default' => [['left' => '$', 'right' => '$']], + 'item_schema' => ['type' => 'array', 'keys' => ['left' => 'string', 'right' => 'string']], + ], + ], + 'block' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'delimiters' => [ + 'type' => 'array', + 'default' => [ + ['left' => '$$', 'right' => '$$'], + ], + 'item_schema' => ['type' => 'array', 'keys' => ['left' => 'string', 'right' => 'string']], + ], + ], + ], + 'quotes' => ['type' => 'boolean', 'default' => true], + 'references' => ['type' => 'boolean', 'default' => true], + 'smartypants' => [ + 'enabled' => ['type' => 'boolean', 'default' => false], + 'smart_angled_quotes' => ['type' => 'boolean', 'default' => true], + 'smart_backticks' => ['type' => 'boolean', 'default' => true], + 'smart_dashes' => ['type' => 'boolean', 'default' => true], + 'smart_ellipses' => ['type' => 'boolean', 'default' => true], + 'smart_quotes' => ['type' => 'boolean', 'default' => true], + 'substitutions' => [ + 'ellipses' => ['type' => 'string', 'default' => '…'], + 'left_angle_quote' => ['type' => 'string', 'default' => '«'], + 'left_double_quote' => ['type' => 'string', 'default' => '“'], + 'left_single_quote' => ['type' => 'string', 'default' => '‘'], + 'mdash' => ['type' => 'string', 'default' => '—'], + 'ndash' => ['type' => 'string', 'default' => '–'], + 'right_angle_quote' => ['type' => 'string', 'default' => '»'], + 'right_double_quote' => ['type' => 'string', 'default' => '”'], + 'right_single_quote' => ['type' => 'string', 'default' => '’'], + ], + ], + 'tables' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'tablespan' => ['type' => 'boolean', 'default' => true], + ], + 'thematic_breaks' => ['type' => 'boolean', 'default' => true], + 'toc' => [ + 'enabled' => ['type' => 'boolean', 'default' => true], + 'levels' => [ + 'type' => 'array', + 'default' => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], + 'item_schema' => ['type' => 'string'], + ], + 'tag' => ['type' => 'string', 'default' => '[TOC]'], + 'id' => ['type' => 'string', 'default' => 'toc'], + ], + 'typographer' => ['type' => 'boolean', 'default' => true], + ]; + } + + + /** + * Retrieve the configuration schema. + * + * This function returns the complete configuration schema that defines the structure, + * expected data types, and default values for all configurable settings. + * + * The schema is used internally for validation and providing type safety when getting or setting configuration values. + * + * @return array The configuration schema as an associative array. + * @since 1.3.0 + * + */ + public function getConfigSchema(): array { + return $this->configSchema; + } + + /** + * Return a new instance of an anonymous configuration class. + * + * This function creates an instance of a class that provides methods to interact with the configuration settings. + * It allows getting and setting configuration values, including translating deprecated keys and validating types. + * + * @return object Anonymous configuration object with get and set methods. + * @since 1.3.0 + * + */ + public function config() { + return new class ($this->configSchema, $this->config) { + private array $schema; + private $config; + + /** + * Constructor to initialize configuration schema and reference configuration array. + * + * @param array $schema The schema that defines the structure and types of config. + * @param array &$config A reference to the actual configuration array. + * @since 0.1.0 + * + */ + public function __construct(array $schema, &$config) { + $this->schema = $schema; + $this->config = &$config; + } + + /** + * Translate deprecated key paths to the new key paths. + * + * This function checks for deprecated configuration keys and suggests a newer version if available. + * + * @param string $keyPath The key path to be translated. + * @return string The translated or original key path. + * @since 1.3.0 + * + */ + private function translateDeprecatedKeyPath(string $keyPath): string { + static $deprecatedMapping = [ + // Mapping of deprecated keys to new keys. + 'abbreviations.allow_custom_abbr' => 'abbreviations.allow_custom', + 'abbreviations.predefine' => 'abbreviations.predefined', + 'emphasis.marking' => 'emphasis.mark', + 'headings.allowed' => 'headings.allowed_levels', + 'smarty' => 'smartypants', + 'smarty.substitutions.left-angle-quote' => 'smartypants.substitutions.left_angle_quote', + 'toc.toc_tag' => 'toc.tag', + 'markup' => 'allow_raw_html', + 'toc.headings' => 'toc.levels', + ]; + + // If the key path is deprecated, trigger a deprecation warning. + if (isset($deprecatedMapping[$keyPath])) { + $backtrace = debug_backtrace(); + $caller = $backtrace[1] ?? $backtrace[0]; + $message = "The config path '{$keyPath}' is deprecated. Use '{$deprecatedMapping[$keyPath]}' instead. Called in ".($caller['file'] ?? 'unknown')." on line ".($caller['line'] ?? 'unknown'); + trigger_error($message, E_USER_DEPRECATED); + } + + return $deprecatedMapping[$keyPath] ?? $keyPath; + } + + /** + * Get the configuration value for the provided key path. + * + * @param string $keyPath Dot-separated key path indicating the config to get. + * @return mixed The value of the configuration setting. + * @throws \InvalidArgumentException If the key path is invalid. + * @since 1.3.0 + * + */ + public function get(string $keyPath, bool $raw = false) { + // Translate deprecated key paths. + $keyPath = $this->translateDeprecatedKeyPath($keyPath); + + // Split the key path into individual keys. + $keys = explode('.', $keyPath); + $value = $this->config; + + // Traverse through keys to reach the desired value. + foreach ($keys as $key) { + if (!array_key_exists($key, $value)) { + $backtrace = debug_backtrace(); + $caller = $backtrace[1] ?? $backtrace[0]; + $errorMessage = "Invalid key path '{$keyPath}' given. Called in ".($caller['file'] ?? 'unknown')." on line ".($caller['line'] ?? 'unknown'); + throw new \InvalidArgumentException($errorMessage); + } + $value = $value[$key]; + } + + if ($raw) { + return $value; + } + + // If the value is an array with an 'enabled' key, return that instead. + return is_array($value) && isset($value['enabled']) ? $value['enabled'] : $value; + } + + /** + * Set the configuration value for the provided key path. + * + * @param string|array $keyPath Dot-separated key path indicating the config to set or an associative array of key paths and values. + * @param mixed $value The value to set. + * @return self Returns the instance for method chaining. + * @throws \InvalidArgumentException If the key path is invalid or the value is of the wrong type. + * @since 1.3.0 + * + */ + public function set($keyPath, $value = null): self { + if (is_array($keyPath)) { + // Set multiple values if an associative array is provided. + foreach ($keyPath as $key => $val) { + $this->set($key, $val); + } + return $this; + } + + // Translate deprecated key paths. + $keyPath = $this->translateDeprecatedKeyPath($keyPath); + + // Split the key path into individual keys. + $keys = explode('.', $keyPath); + $lastKey = array_pop($keys); + + $current = &$this->config; + $currentSchema = $this->schema; + + // Navigate to the desired configuration section. + foreach ($keys as $key) { + if (!isset($current[$key])) { + $backtrace = debug_backtrace(); + $caller = $backtrace[1] ?? $backtrace[0]; + $errorMessage = "Invalid key path '{$keyPath}' given. Called in ".($caller['file'] ?? 'unknown')." on line ".($caller['line'] ?? 'unknown'); + throw new \InvalidArgumentException($errorMessage); + } + $current = &$current[$key]; + if (!isset($currentSchema[$key])) { + throw new \InvalidArgumentException("Invalid schema path: ".implode('.', $keys)); + } + $currentSchema = $currentSchema[$key]; + } + + // Validate and set the value for the specified key. + if (isset($currentSchema['default'][$lastKey])) { + $expectedType = $currentSchema['default'][$lastKey]['type']; + $this->validateType($value, $expectedType, $currentSchema['default'][$lastKey]); + $current[$lastKey] = $value; + } else { + if (!isset($currentSchema[$lastKey])) { + $backtrace = debug_backtrace(); + $caller = $backtrace[1] ?? $backtrace[0]; + $errorMessage = "Invalid key path '{$keyPath}' given. Called in ".($caller['file'] ?? 'unknown')." on line ".($caller['line'] ?? 'unknown'); + throw new \InvalidArgumentException($errorMessage); + } + $expectedType = $currentSchema[$lastKey]['type'] ?? null; + if ($expectedType) { + $this->validateType($value, $expectedType, $currentSchema[$lastKey]); + } + // Update the 'enabled' field if applicable. + if (isset($current[$lastKey]) && is_array($current[$lastKey]) && isset($current[$lastKey]['enabled'])) { + + /** + * If the value is an array, it recursively sets each sub-value. + * Otherwise, it sets the 'enabled' key of the current configuration. + */ + if (is_array($value)) { + foreach ($value as $subKey => $subValue) { + $this->set($keyPath.'.'.$subKey, $subValue); + } + } else { + $current[$lastKey]['enabled'] = $value; + } + + } else { + $current[$lastKey] = $value; + } + } + + return $this; + } + + /** + * Validate the type of the given value against the expected type. + * + * @param mixed $value The value to be validated. + * @param string $expectedType The expected type of the value. + * @param array|null $schema Additional schema for validation (e.g., item schema for arrays). + * @throws \InvalidArgumentException If the value type does not match the expected type. + * @since 1.3.0 + * + */ + protected function validateType($value, string $expectedType, ?array $schema = null): void { + $type = gettype($value); + + if ($expectedType === 'array' && $type === 'array') { + if (isset($schema['item_schema'])) { + if (isset($schema['item_schema']['key_type']) && isset($schema['item_schema']['value_type'])) { + // Validate key-value pairs in the array. + $keyType = $schema['item_schema']['key_type']; + $valueType = $schema['item_schema']['value_type']; + + foreach ($value as $key => $item) { + if (gettype($key) !== $keyType || gettype($item) !== $valueType) { + $backtrace = debug_backtrace(); + $caller = $backtrace[1] ?? $backtrace[0]; + $errorMessage = "Array keys must be of type '$keyType' and values of type '$valueType'. Called in ".($caller['file'] ?? 'unknown')." on line ".($caller['line'] ?? 'unknown'); + throw new \InvalidArgumentException($errorMessage); + } + } + return; + } + } + return; + } + + // If types do not match, throw an error with debug information. + if ($type !== $expectedType) { + $backtrace = debug_backtrace(); + $caller = $backtrace[1] ?? $backtrace[0]; + $errorMessage = "Expected type $expectedType, got $type. Called in ".($caller['file'] ?? 'unknown')." on line ".($caller['line'] ?? 'unknown'); + throw new \InvalidArgumentException($errorMessage); + } + } + }; + } + + + // Overwriting core Parsedown functions + // ------------------------------------------------------------------------- + + /** + * Handle an element based on the legacy mode. + * + * This function extends the core Parsedown behavior to handle specific cases + * when in legacy mode, particularly for empty element names. + * + * @param array $Element The element to be processed. + * @return string|array Processed element or markup. + * @since 0.1.0 + * + */ + protected function element(array $Element) { + if ($this->legacyMode) { + // If the element's name is empty, return the text attribute + if (empty($Element['name'])) { + return $Element['text'] ?? ''; + } + } + + // Use the original element method from the parent + return parent::element($Element); + } + + /** + * Process a line of markdown text and extract inline elements. + * + * This function processes a line of markdown text by iteratively searching for + * markers in the text, and applies the appropriate inline handlers for those markers. + * + * @param string $text The text to be parsed for inline elements. + * @param array $nonNestables Array of inline types that should not be nested. + * @return string The parsed HTML markup for the given line. + * @since 0.1.0 + * + */ + public function line($text, $nonNestables = []) { + $markup = ''; + + // Search for inline markers in the text + while ($Excerpt = strpbrk((string)$text, $this->inlineMarkerList)) { + $marker = $Excerpt[0]; + $markerPosition = strpos($text, $marker); + + // Get the character before the marker + $before = $markerPosition > 0 ? $text[$markerPosition - 1] : ''; + + // Create an excerpt array with context for inline processing + $Excerpt = [ + 'text' => $Excerpt, + 'context' => $text, + 'before' => $before, + 'parent' => $this, + ]; + + // Iterate through possible inline types for the marker + foreach ($this->InlineTypes[$marker] as $inlineType) { + if (!empty($nonNestables) && in_array($inlineType, $nonNestables)) { + continue; // Skip non-nestable inline types in this context + } + + // Attempt to create an inline element using the handler + $Inline = $this->{'inline'.$inlineType}($Excerpt); + + if (!isset($Inline)) { + continue; // If no inline element was found, continue to the next type + } + + if (isset($Inline['position']) && $Inline['position'] > $markerPosition) { + continue; // Ensure the inline belongs to the current marker + } + + // Set a default position if not provided + if (!isset($Inline['position'])) { + $Inline['position'] = $markerPosition; + } + + // Add non-nestables to the inline element + foreach ($nonNestables as $non_nestable) { + $Inline['element']['nonNestables'][] = $non_nestable; + } + + // Compile the text that comes before the inline element + $unmarkedText = substr($text, 0, $Inline['position']); + $markup .= $this->unmarkedText($unmarkedText); + + // Compile the inline element + $markup .= $Inline['markup'] ?? $this->element($Inline['element']); + + // Remove the processed text from the input + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; // Continue parsing the rest of the text + } + + // If no valid inline marker was found, add the marker to the markup + $unmarkedText = substr($text, 0, $markerPosition + 1); + $markup .= $this->unmarkedText($unmarkedText); + $text = substr($text, $markerPosition + 1); + } + + // Compile the remaining text + $markup .= $this->unmarkedText($text); + + return $markup; + } + + + /** + * Parses a line of text into inline elements. + * + * This function processes the given text, identifying markers and breaking it into inline elements. + * Inline elements include things like bold, italic, links, etc. It recursively handles nesting and respects + * non-nestable contexts. + * + * lineElements() is 1.8 version of line() from 1.7 + * + * @param string $text The text to be parsed. + * @param array $nonNestables An array of inline types that should not be nested within this context. + * + * @return array An array of parsed elements representing the structure of the given text. + * @since 0.1.0 + * + */ + protected function lineElements($text, $nonNestables = []): array { + $Elements = []; + + // If non-nestable elements are provided, convert them to associative array for fast lookup + $nonNestables = ( + empty($nonNestables) + ? [] + : array_combine($nonNestables, $nonNestables) + ); + + // $Excerpt represents the first occurrence of an inline marker in the text + while ($Excerpt = strpbrk($text, $this->inlineMarkerList)) { + $marker = $Excerpt[0]; // The detected marker + $markerPosition = strlen($text) - strlen($Excerpt); // Calculate the marker position in the text + + // Get the character before the marker (if any) + $before = $markerPosition > 0 ? $text[$markerPosition - 1] : ''; + + // Prepare an excerpt for further processing + $Excerpt = ['text' => $Excerpt, 'context' => $text, 'before' => $before]; + + // Process all inline types associated with this marker + foreach ($this->InlineTypes[$marker] as $inlineType) { + // Skip inline types that are non-nestable within this context + if (isset($nonNestables[$inlineType])) { + continue; + } + + // Call the corresponding inline processing function + $Inline = $this->{"inline$inlineType"}($Excerpt); + + // If no valid inline element was found, continue to the next inline type + if (!isset($Inline)) { + continue; + } + + // Ensure the inline element belongs to the current marker + if (isset($Inline['position']) && $Inline['position'] > $markerPosition) { + continue; + } + + // Set default inline position if not specified + if (!isset($Inline['position'])) { + $Inline['position'] = $markerPosition; + } + + // Inherit non-nestable elements from the current context + $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables']) + ? array_merge($Inline['element']['nonNestables'], $nonNestables) + : $nonNestables; + + // Get the text before the inline marker + $unmarkedText = substr($text, 0, $Inline['position']); + + // Process and add the unmarked text as an element + $InlineText = $this->inlineText($unmarkedText); + $Elements[] = $InlineText['element']; + + // Process and add the inline element + $Elements[] = $this->extractElement($Inline); + + // Remove the processed portion from the text and continue parsing + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; + } + + // If no valid inline element was found for the marker, treat it as plain text + $unmarkedText = substr($text, 0, $markerPosition + 1); + + // Process and add the unmarked text as an element + $InlineText = $this->inlineText($unmarkedText); + $Elements[] = $InlineText['element']; + + // Remove the processed portion from the text + $text = substr($text, $markerPosition + 1); + } + + // Process any remaining text after all markers + $InlineText = $this->inlineText($text); + $Elements[] = $InlineText['element']; + + // Set the `autobreak` property for each element, defaulting to false if not already set + foreach ($Elements as &$Element) { + if (!isset($Element['autobreak'])) { + $Element['autobreak'] = false; + } + } + + return $Elements; + } +}