phpOMS/Utils/Parser/Markdown/Markdown.php
Dennis Eichhorn ca57051cd7 test fixes
2024-04-24 20:02:48 +00:00

4768 lines
159 KiB
PHP
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Jingga
*
* PHP Version 8.2
*
* @package phpOMS\Utils\Parser\Markdown
* @copyright Original & extra license Emanuil Rusev, erusev.com (MIT)
* @copyright Extended license Benjamin Hoegh (MIT)
* @copyright Extreme license doowzs (MIT)
* @license This version: OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*/
declare(strict_types=1);
namespace phpOMS\Utils\Parser\Markdown;
use phpOMS\Uri\UriFactory;
/**
* Markdown parser class.
*
* @package phpOMS\Utils\Parser\Markdown
* @copyright Original & extra license Emanuil Rusev, erusev.com (MIT)
* @copyright Extended license Benjamin Hoegh (MIT)
* @copyright Extreme license doowzs (MIT)
* @license This version: OMS License 2.0
* @link https://jingga.app
* @see https://github.com/erusev/parsedown
* @see https://github.com/erusev/parsedown-extra
* @see https://github.com/BenjaminHoegh/ParsedownExtended
* @see https://github.com/doowzs/parsedown-extreme
* @since 1.0.0
*
* @todo Add special markdown content
* 1. Calendar (own widget)
* 2. Event (own widget)
* 3. Tasks (own widget)
* 4. Vote/Survey (own widget)
* 5. Website link/embed widgets (facebook, linkedIn, twitter, ...)
* 6. User/Supplier/Client/Employee (own widget, should make use of schema)
* 7. Address (own widget, should make use of schema)
* 8. Contact (own widget, should make use of schema)
* 9. Item (own widget, should make use of schema)
* 10. Progress radial
* 11. Timeline horizontal/vertical/matrix
* 12. Tabs horizontal/vertical
* 13. Checklist (own widget)
* 14. Gallery
* 15. Form (own widget)
* https://github.com/Karaka-Management/phpOMS/issues/290
*/
class Markdown
{
/**
* Parsedown version
*
* @var string
* @since 1.0.0
*/
public const VERSION = '2.0.0';
/**
* Special markdown characters
*
* @var string[]
* @since 1.0.0
*/
protected array $specialCharacters = [
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '?', '"', "'", '<',
];
/**
* Regexes for html strong
*
* @var array<string, string>
* @since 1.0.0
*/
protected array $strongRegex = [
'*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s',
];
/**
* Regexes for html underline
*
* @var array<string, string>
* @since 1.0.0
*/
protected array $underlineRegex = [
'_' => '/^[_]{2}((?:\\\\\_|[^_]|[_][^_]_+[_])+?)[_]{2}(?![_])/s',
];
/**
* Regexes for html emphasizes
*
* @var array<string, string>
* @since 1.0.0
*/
protected array $emRegex = [
'*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
'_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
];
/**
* Regex for html attributes
*
* @var string
* @since 1.0.0
*/
protected string $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+';
/**
* Regex for for classes and ids
*
* @var string
* @since 1.0.0
*/
protected string $regexAttribute = '(?:[#.][-\w]+[ ]*)';
/**
* Elements without closing
*
* @var string[]
* @since 1.0.0
*/
protected array $voidElements = [
'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
];
/**
* Text elements
*
* @var string[]
* @since 1.0.0
*/
protected array $textLevelElements = [
'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
'i', 'rp', 'del', 'code', 'strike', 'marquee',
'q', 'rt', 'ins', 'font', 'strong',
's', 'tt', 'kbd', 'mark',
'u', 'xm', 'sub', 'nobr',
'sup', 'ruby',
'var', 'span',
'wbr', 'time',
];
/**
* Inline special characters (not block elements)
*
* @var array<string, string[]>
* @since 1.0.0
*/
protected array $inlineTypes = [
'!' => ['Image'],
'*' => ['Emphasis'],
'_' => ['Emphasis'],
'&' => ['SpecialCharacter'],
'[' => ['FootnoteMarker', 'Link'],
':' => ['Url'],
'<' => ['UrlTag', 'EmailTag', 'Markup'],
'`' => ['Code'],
'~' => ['Strikethrough'],
'\\' => ['EscapeSequence'],
];
/**
* Inline special characters (not block elements) (see $inlineTypes)
*
* @var string
* @since 1.0.0
*/
protected string $inlineMarkerList = '!*_&[:<`~';
/**
* Uses strict mode?
*
* Less forgiving with regards to formatting.
*
* @var bool
* @since 1.0.0
*/
public bool $strictMode = false;
/**
* Uses safe mode (true -> no html allowed)?
*
* Important for parsing or sanitizing html.
*
* @var bool
* @since 1.0.0
*/
public bool $safeMode = false;
/**
* Urls are always handled as links
*
* @var bool
* @since 1.0.0
*/
public bool $urlsLinked = true;
/**
* Should html get escaped?
*
* @var bool
* @since 1.0.0
*/
public bool $markupEscaped = false;
/**
* Replaces new lines with <br> in inline elements
*
* true -> replaces new line with br
* false -> replaces double whitespace followed with new line with br
*
* @var bool
* @since 1.0.0
*/
public bool $breaksEnabled = false;
/**
* Save link prefixes
*
* @var string[]
* @since 1.0.0
*/
protected array $safeLinksWhitelist = [
'http://',
'https://',
'ftp://',
'ftps://',
'mailto:',
'tel:',
'data:image/png;base64,',
'data:image/gif;base64,',
'data:image/jpeg;base64,',
'irc:',
'ircs:',
'git:',
'ssh:',
'news:',
'steam:',
];
/**
* Block special characters
*
* @var array<int|string, string[]>
* @since 1.0.0
*/
protected array $blockTypes = [
'#' => ['Header'],
'*' => ['Rule', 'List', 'Abbreviation'],
'+' => ['List'],
'-' => ['SetextHeader', 'Table', 'Rule', 'List'],
'0' => ['List'],
'1' => ['List'],
'2' => ['List'],
'3' => ['List'],
'4' => ['List'],
'5' => ['List'],
'6' => ['List'],
'7' => ['List'],
'8' => ['List'],
'9' => ['List'],
':' => ['Table', 'DefinitionList'],
'<' => ['Comment', 'Markup'],
'=' => ['SetextHeader'],
'>' => ['Quote'],
'[' => ['Footnote', 'Reference'],
'_' => ['Rule'],
'`' => ['FencedCode'],
'|' => ['Table'],
];
/**
* Unmarked block types
*
* @var string[]
* @since 1.0.0
*/
protected array $unmarkedBlockTypes = [
'Code',
];
/**
* Is continuable
*
* @var string[]
* @since 1.0.0
*/
private const CONTINUABLE = [
'Code', 'Comment', 'FencedCode', 'List', 'Quote', 'Table', 'Math', 'Spoiler', 'Checkbox', 'Footnote', 'DefinitionList', 'Markup',
];
/**
* Is completable
*
* @var string[]
* @since 1.0.0
*/
private const COMPLETABLE = [
'Math', 'Spoiler', 'Table', 'Checkbox', 'Footnote', 'Markup', 'Code', 'FencedCode', 'List',
];
/**
* Parsing options
*
* @var array
* @since 1.0.0
*/
private array $options = [];
/**
* Definition data
*
* E.g. abbreviations, footnotes
*
* @var array
* @since 1.0.0
*/
protected array $definitionData = [];
// TOC: start
/**
* Table of content id
*
* @var string
* @since 1.0.0
*/
public string $idToc = 'toc';
/**
* TOC array after parsing headers
*
* @var array{}|array{text:string, id:string, level:string}
* @since 1.0.0
*/
protected array $contentsListArray = [];
/**
* TOC string after parsing headers
*
* @var string
* @since 1.0.0
*/
protected string $contentsListString = '';
/**
* First head level
*
* @var int
* @since 1.0.0
*/
protected int $firstHeadLevel = 0;
/**
* Is header blacklist (for table of contents/TOC) initialized
*
* @var bool
* @since 1.0.0
*/
protected bool $isBlacklistInitialized = false;
/**
* Header duplicates (same header text)
*
* @var array<string, int>
* @since 1.0.0
*/
protected array $anchorDuplicates = [];
// TOC: end
/**
* Footnote count
*
* @var int
* @since 1.0.0
*/
private int $footnoteCount = 0;
/**
* Current abbreviation
*
* @var string
* @since 1.0.0
*/
private string $currentAbbreviation;
/**
* Current abbreviation meaning
*
* @var string
* @since 1.0.0
*/
private string $currentMeaning;
/**
* Instances
*
* @var array<string, self>
* @since 1.0.0
*/
private static $instances = [];
/**
* Clean up state
*
* @return void
*
* @since 1.0.0
*/
public function clean() : void
{
$this->definitionData = [];
$this->contentsListArray = [];
$this->contentsListString = '';
$this->firstHeadLevel = 0;
$this->anchorDuplicates = [];
$this->footnoteCount = 0;
$this->currentAbbreviation = '';
$this->currentMeaning = '';
}
/**
* Create instance for static use
*
* @param string $name Instance name
*
* @return self
*
* @since 1.0.0
*/
public static function getInstance(string $name = 'default') : self
{
if (isset(self::$instances[$name])) {
$obj = self::$instances[$name];
$obj->clean();
return self::$instances[$name];
}
$instance = new self();
self::$instances[$name] = $instance;
return $instance;
}
/**
* Constructor.
*
* @param array $params Parameters
*
* @since 1.0.0
*/
public function __construct(array $params = [])
{
$this->options = $params;
$this->options['toc'] = $this->options['toc'] ?? false;
// Marks
if ($this->options['mark'] ?? true) {
$this->inlineTypes['='][] = 'Mark';
$this->inlineMarkerList .= '=';
}
// Keystrokes
if ($this->options['keystrokes'] ?? true) {
$this->inlineTypes['['][] = 'Keystrokes';
}
// Spoiler
if ($this->options['spoiler'] ?? false) {
$this->inlineTypes['>'][] = 'Spoiler';
$this->inlineMarkerList .= '>';
}
// Inline Math
if ($this->options['math'] ?? false) {
$this->inlineTypes['\\'][] = 'Math';
$this->inlineTypes['$'][] = 'Math';
$this->inlineMarkerList .= '$';
}
// Superscript
if ($this->options['sup'] ?? false) {
$this->inlineTypes['^'][] = 'Superscript';
$this->inlineMarkerList .= '^';
}
// Subscript
if ($this->options['sub'] ?? false) {
$this->inlineTypes['~'][] = 'Subscript';
}
// Emojis
if ($this->options['emojis'] ?? true) {
$this->inlineTypes[':'][] = 'Emojis';
}
// Typographer
if ($this->options['typographer'] ?? false) {
$this->inlineTypes['('][] = 'Typographer';
$this->inlineMarkerList .= '(';
$this->inlineTypes['.'][] = 'Typographer';
$this->inlineMarkerList .= '.';
$this->inlineTypes['+'][] = 'Typographer';
$this->inlineMarkerList .= '+';
$this->inlineTypes['!'][] = 'Typographer';
$this->inlineTypes['?'][] = 'Typographer';
$this->inlineMarkerList .= '?';
}
// Block Math
if ($this->options['math'] ?? false) {
$this->blockTypes['\\'][] = 'Math';
$this->blockTypes['$'][] = 'Math';
}
// Block Spoiler
if ($this->options['spoiler'] ?? false) {
$this->blockTypes['?'][] = 'Spoiler';
}
// Checkbox
if ($this->options['lists']['checkbox'] ?? true) {
$this->blockTypes['['][] = 'Checkbox';
}
// Embedding
if ($this->options['embedding'] ?? false) {
$this->inlineTypes['['][] = 'Embedding';
}
// Map
if ($this->options['map'] ?? false) {
$this->inlineTypes['['][] = 'Map';
}
// Address
if ($this->options['address'] ?? false) {
$this->inlineTypes['['][] = 'Address';
}
// Contact
if ($this->options['contact'] ?? false) {
$this->inlineTypes['['][] = 'Contact';
}
// Progress
if ($this->options['progress'] ?? false) {
$this->inlineTypes['['][] = 'Progress';
}
// Escaping needs to happen at the end
$this->inlineMarkerList .= '\\';
}
/**
* Parses the given markdown string to a HTML
*
* @param string $text Markdown text to parse
*
* @return string
*
* @since 1.0.0
*/
public static function parse(string $text) : string
{
$parsedown = self::getInstance();
return $parsedown->text($text);
}
/**
* Parses the given markdown string to a HTML string but it ignores ToC
*
* @param string $text Markdown text to parse
*
* @return string
*
* @since 1.0.0
*/
public function body(string $text) : string
{
$text = $this->encodeToCTagToHash($text); // Escapes ToC tag temporary
$elements = $this->textElements($text);
$html = $this->elements($elements);
$html = \trim($html, "\n");
// Merge consecutive dl elements
$html = \preg_replace('/<\/dl>\s+<dl>\s+/', '', $html) ?? '';
// Add footnotes
if (isset($this->definitionData['Footnote'])) {
$element = $this->buildFootnoteElement();
$html .= "\n" . $this->element($element);
}
return $this->decodeToCTagFromHash($html); // Unescape the ToC tag
}
/**
* Parses markdown string to HTML and also the "[toc]" tag as well.
*
* @param string $text Markdown text to parse
*
* @return string
*
* @since 1.0.0
*/
public function text(string $text) : string
{
// Parses the markdown text except the ToC tag. This also searches
// the list of contents and available to get from "contentsList()"
// method.
$html = $this->body($text);
if (isset($this->options['toc']) && $this->options['toc'] === false) {
return $html;
}
// Handle toc
$tagOrigin = $this->options['toc']['set_toc_tag'] ?? '[toc]';
if (\strpos($text, $tagOrigin) === false) {
return $html;
}
$tocData = $this->contentsList();
$needle = '<p>' . $tagOrigin . '</p>';
$replace = '<div id="' . $this->idToc . '">' . $tocData . '</div>';
return \str_replace($needle, $replace, $html);
}
/**
* Returns the parsed ToC.
*
* @param string $typeReturn Type of the return format. "html" or "json".
*
* @return string HTML/JSON string of ToC
*
* @since 1.0.0
*/
public function contentsList($typeReturn = 'html') : string
{
if (\strtolower($typeReturn) === 'json') {
$json = \json_encode($this->contentsListArray);
return $json === false ? '' : $json;
}
$result = '';
if (!empty($this->contentsListString)) {
// Parses the ToC list in markdown to HTML
$result = $this->body($this->contentsListString);
}
return $result;
}
/**
* Handle inline code
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineCode(array $excerpt) : ?array
{
if (!($this->options['code']['inline'] ?? true)
|| !($this->options['code'] ?? true)
) {
return null;
}
$marker = $excerpt['text'][0];
if (\preg_match(
'/^([' . $marker . ']++)[ ]*+(.+?)[ ]*+(?<![' . $marker . '])\1(?!' . $marker . ')/s',
$excerpt['text'], $matches
) !== 1
) {
return null;
}
$text = $matches[2];
$text = \preg_replace('/[ ]*+\n/', ' ', $text);
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'code',
'text' => $text,
],
];
}
/**
* Handle inline email
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineEmailTag(array $excerpt) : ?array
{
if (!($this->options['links'] ?? true)
|| !($this->options['links']['email_links'] ?? true)
) {
return null;
}
$hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
$commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@' . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*';
if (\strpos($excerpt['text'], '>') === false
|| \preg_match('/^<((mailto:)?' . $commonMarkEmail . ')>/i', $excerpt['text'], $matches) !== 1
) {
return null;
}
$url = UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[1]));
if (!isset($matches[2])) {
$url = "mailto:{$url}";
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'a',
'text' => $matches[1],
'attributes' => [
'href' => $url,
],
],
];
}
/**
* Inline emphasis
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineEmphasis(array $excerpt) : ?array
{
if (!($this->options['emphasis'] ?? true)
|| !isset($excerpt['text'][1])
) {
return null;
}
$marker = $excerpt['text'][0];
if ($excerpt['text'][1] === $marker
&& isset($this->strongRegex[$marker]) && \preg_match($this->strongRegex[$marker], $excerpt['text'], $matches)
) {
$emphasis = 'strong';
} elseif ($excerpt['text'][1] === $marker
&& isset($this->underlineRegex[$marker]) && \preg_match($this->underlineRegex[$marker], $excerpt['text'], $matches)
) {
$emphasis = 'u';
} elseif (\preg_match($this->emRegex[$marker], $excerpt['text'], $matches)) {
$emphasis = 'em';
} else {
return null;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => $emphasis,
'handler' => [
'function' => 'lineElements',
'argument' => $matches[1],
'destination' => 'elements',
],
],
];
}
/**
* Handle image
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineImage(array $excerpt) : ?array
{
if (!($this->options['images'] ?? true)
|| !\str_starts_with($excerpt['text'], '![')
) {
return null;
}
$excerpt['text'] = \substr($excerpt['text'], 1);
$link = $this->inlineLink($excerpt);
if ($link === null) {
return null;
}
$inline = [
'extent' => $link['extent'] + 1,
'element' => [
'name' => 'img',
'attributes' => [
'src' => $link['element']['attributes']['href'],
'alt' => $link['element']['handler']['argument'],
],
'autobreak' => true,
],
];
$inline['element']['attributes'] += $link['element']['attributes'];
unset($inline['element']['attributes']['href']);
return $inline;
}
/**
* Handle link
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array
*
* @since 1.0.0
*/
protected function inlineLink(array $excerpt) : ?array
{
if (!($this->options['links'] ?? true)) {
return null;
}
$link = $this->inlineLinkParent($excerpt);
$remainder = $link !== null ? \substr($excerpt['text'], $link['extent']) : '';
if (\preg_match('/^[ ]*{(' . $this->regexAttribute . '+)}/', $remainder, $matches)) {
$link['extent'] += \strlen($matches[0]);
$link['element']['attributes'] += $this->parseAttributeData($matches[1]);
}
return $link;
}
/**
* Handle markup
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineMarkup(array $excerpt) : ?array
{
if (!($this->options['markup'] ?? true)
|| $this->markupEscaped || $this->safeMode || \strpos($excerpt['text'], '>') === false
) {
return null;
}
$matches = [];
if (($excerpt['text'][1] === '/' && \preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $excerpt['text'], $matches))
|| ($excerpt['text'][1] === '!' && \preg_match('/^<!---?[^>-](?:-?+[^-])*-->/s', $excerpt['text'], $matches))
|| ($excerpt['text'][1] !== ' ' && \preg_match('/^<\w[\w-]*+(?:[ ]*+' . $this->regexHtmlAttribute . ')*+[ ]*+\/?>/s', $excerpt['text'], $matches))
) {
return [
'extent' => \strlen($matches[0]),
'element' => ['rawHtml' => $matches[0]],
];
}
return null;
}
/**
* Handle strikethrough
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineStrikethrough(array $excerpt) : ?array
{
if (!($this->options['strikethroughs'] ?? true)
|| !\str_starts_with($excerpt['text'], '~~')
|| \preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $excerpt['text'], $matches) !== 1
) {
return null;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'del',
'handler' => [
'function' => 'lineElements',
'argument' => $matches[1],
'destination' => 'elements',
],
],
];
}
/**
* Handle url
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, position:int, element:array}
*
* @since 1.0.0
*/
protected function inlineUrl(array $excerpt) : ?array
{
if (!($this->options['links'] ?? true)
|| !$this->urlsLinked || !\str_starts_with($excerpt['text'], '://')
|| \strpos($excerpt['context'], 'http') === false
|| \preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $excerpt['context'], $matches, \PREG_OFFSET_CAPTURE) !== 1
) {
return null;
}
$url = UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[0][0]));
return [
'extent' => \strlen($matches[0][0]),
'position' => $matches[0][1],
'element' => [
'name' => 'a',
'text' => $url,
'attributes' => [
'href' => $url,
],
],
];
}
/**
* Handle url
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineUrlTag(array $excerpt) : ?array
{
if (!($this->options['links'] ?? true)
|| \strpos($excerpt['text'], '>') === false
|| \preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $excerpt['text'], $matches) !== 1
) {
return null;
}
$url = UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[1]));
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'a',
'text' => $url,
'attributes' => [
'href' => $url,
],
],
];
}
/**
* Handle emojis
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineEmojis(array $excerpt) : ?array
{
if (\preg_match('/^(:)([^: ]*?)(:)/', $excerpt['text'], $matches) !== 1) {
return null;
}
$emojiMap = [
':smile:' => '😄', ':laughing:' => '😆', ':blush:' => '😊', ':smiley:' => '😃',
':relaxed:' => '☺️', ':smirk:' => '😏', ':heart_eyes:' => '😍', ':kissing_heart:' => '😘',
':kissing_closed_eyes:' => '😚', ':flushed:' => '😳', ':relieved:' => '😌', ':satisfied:' => '😆',
':grin:' => '😁', ':wink:' => '😉', ':stuck_out_tongue_winking_eye:' => '😜', ':stuck_out_tongue_closed_eyes:' => '😝',
':grinning:' => '😀', ':kissing:' => '😗', ':kissing_smiling_eyes:' => '😙', ':stuck_out_tongue:' => '😛',
':sleeping:' => '😴', ':worried:' => '😟', ':frowning:' => '😦', ':anguished:' => '😧',
':open_mouth:' => '😮', ':grimacing:' => '😬', ':confused:' => '😕', ':hushed:' => '😯',
':expressionless:' => '😑', ':unamused:' => '😒', ':sweat_smile:' => '😅', ':sweat:' => '😓',
':disappointed_relieved:' => '😥', ':weary:' => '😩', ':pensive:' => '😔', ':disappointed:' => '😞',
':confounded:' => '😖', ':fearful:' => '😨', ':cold_sweat:' => '😰', ':persevere:' => '😣',
':cry:' => '😢', ':sob:' => '😭', ':joy:' => '😂', ':astonished:' => '😲',
':scream:' => '😱', ':tired_face:' => '😫', ':angry:' => '😠', ':rage:' => '😡',
':triumph:' => '😤', ':sleepy:' => '😪', ':yum:' => '😋', ':mask:' => '😷',
':sunglasses:' => '😎', ':dizzy_face:' => '😵', ':imp:' => '👿', ':smiling_imp:' => '😈',
':neutral_face:' => '😐', ':no_mouth:' => '😶', ':innocent:' => '😇', ':alien:' => '👽',
':yellow_heart:' => '💛', ':blue_heart:' => '💙', ':purple_heart:' => '💜', ':heart:' => '❤️',
':green_heart:' => '💚', ':broken_heart:' => '💔', ':heartbeat:' => '💓', ':heartpulse:' => '💗',
':two_hearts:' => '💕', ':revolving_hearts:' => '💞', ':cupid:' => '💘', ':sparkling_heart:' => '💖',
':sparkles:' => '✨', ':star:' => '⭐️', ':star2:' => '🌟', ':dizzy:' => '💫',
':boom:' => '💥', ':collision:' => '💥', ':anger:' => '💢', ':exclamation:' => '❗️',
':question:' => '❓', ':grey_exclamation:' => '❕', ':grey_question:' => '❔', ':zzz:' => '💤',
':dash:' => '💨', ':sweat_drops:' => '💦', ':notes:' => '🎶', ':musical_note:' => '🎵',
':fire:' => '🔥', ':hankey:' => '💩', ':poop:' => '💩', ':shit:' => '💩',
':+1:' => '👍', ':thumbsup:' => '👍', ':-1:' => '👎', ':thumbsdown:' => '👎',
':ok_hand:' => '👌', ':punch:' => '👊', ':facepunch:' => '👊', ':fist:' => '✊',
':v:' => '✌️', ':wave:' => '👋', ':hand:' => '✋', ':raised_hand:' => '✋',
':open_hands:' => '👐', ':point_up:' => '☝️', ':point_down:' => '👇', ':point_left:' => '👈',
':point_right:' => '👉', ':raised_hands:' => '🙌', ':pray:' => '🙏', ':point_up_2:' => '👆',
':clap:' => '👏', ':muscle:' => '💪', ':metal:' => '🤘', ':fu:' => '🖕',
':walking:' => '🚶', ':runner:' => '🏃', ':running:' => '🏃', ':couple:' => '👫',
':family:' => '👪', ':two_men_holding_hands:' => '👬', ':two_women_holding_hands:' => '👭', ':dancer:' => '💃',
':dancers:' => '👯', ':ok_woman:' => '🙆', ':no_good:' => '🙅', ':information_desk_person:' => '💁',
':raising_hand:' => '🙋', ':bride_with_veil:' => '👰', ':person_with_pouting_face:' => '🙎', ':person_frowning:' => '🙍',
':bow:' => '🙇', ':couple_with_heart:' => '💑', ':massage:' => '💆', ':haircut:' => '💇',
':nail_care:' => '💅', ':boy:' => '👦', ':girl:' => '👧', ':woman:' => '👩',
':man:' => '👨', ':baby:' => '👶', ':older_woman:' => '👵', ':older_man:' => '👴',
':person_with_blond_hair:' => '👱', ':man_with_gua_pi_mao:' => '👲', ':man_with_turban:' => '👳', ':construction_worker:' => '👷',
':cop:' => '👮', ':angel:' => '👼', ':princess:' => '👸', ':smiley_cat:' => '😺',
':smile_cat:' => '😸', ':heart_eyes_cat:' => '😻', ':kissing_cat:' => '😽', ':smirk_cat:' => '😼',
':scream_cat:' => '🙀', ':crying_cat_face:' => '😿', ':joy_cat:' => '😹', ':pouting_cat:' => '😾',
':japanese_ogre:' => '👹', ':japanese_goblin:' => '👺', ':see_no_evil:' => '🙈', ':hear_no_evil:' => '🙉',
':speak_no_evil:' => '🙊', ':guardsman:' => '💂', ':skull:' => '💀', ':feet:' => '🐾',
':lips:' => '👄', ':kiss:' => '💋', ':droplet:' => '💧', ':ear:' => '👂',
':eyes:' => '👀', ':nose:' => '👃', ':tongue:' => '👅', ':love_letter:' => '💌',
':bust_in_silhouette:' => '👤', ':busts_in_silhouette:' => '👥', ':speech_balloon:' => '💬', ':thought_balloon:' => '💭',
':sunny:' => '☀️', ':umbrella:' => '☔️', ':cloud:' => '☁️', ':snowflake:' => '❄️',
':snowman:' => '⛄️', ':zap:' => '⚡️', ':cyclone:' => '🌀', ':foggy:' => '🌁',
':ocean:' => '🌊', ':cat:' => '🐱', ':dog:' => '🐶', ':mouse:' => '🐭',
':hamster:' => '🐹', ':rabbit:' => '🐰', ':wolf:' => '🐺', ':frog:' => '🐸',
':tiger:' => '🐯', ':koala:' => '🐨', ':bear:' => '🐻', ':pig:' => '🐷',
':pig_nose:' => '🐽', ':cow:' => '🐮', ':boar:' => '🐗', ':monkey_face:' => '🐵',
':monkey:' => '🐒', ':horse:' => '🐴', ':racehorse:' => '🐎', ':camel:' => '🐫',
':sheep:' => '🐑', ':elephant:' => '🐘', ':panda_face:' => '🐼', ':snake:' => '🐍',
':bird:' => '🐦', ':baby_chick:' => '🐤', ':hatched_chick:' => '🐥', ':hatching_chick:' => '🐣',
':chicken:' => '🐔', ':penguin:' => '🐧', ':turtle:' => '🐢', ':bug:' => '🐛',
':honeybee:' => '🐝', ':ant:' => '🐜', ':beetle:' => '🐞', ':snail:' => '🐌',
':octopus:' => '🐙', ':tropical_fish:' => '🐠', ':fish:' => '🐟', ':whale:' => '🐳',
':whale2:' => '🐋', ':dolphin:' => '🐬', ':cow2:' => '🐄', ':ram:' => '🐏',
':rat:' => '🐀', ':water_buffalo:' => '🐃', ':tiger2:' => '🐅', ':rabbit2:' => '🐇',
':dragon:' => '🐉', ':goat:' => '🐐', ':rooster:' => '🐓', ':dog2:' => '🐕',
':pig2:' => '🐖', ':mouse2:' => '🐁', ':ox:' => '🐂', ':dragon_face:' => '🐲',
':blowfish:' => '🐡', ':crocodile:' => '🐊', ':dromedary_camel:' => '🐪', ':leopard:' => '🐆',
':cat2:' => '🐈', ':poodle:' => '🐩', ':crab' => '🦀', ':paw_prints:' => '🐾', ':bouquet:' => '💐',
':cherry_blossom:' => '🌸', ':tulip:' => '🌷', ':four_leaf_clover:' => '🍀', ':rose:' => '🌹',
':sunflower:' => '🌻', ':hibiscus:' => '🌺', ':maple_leaf:' => '🍁', ':leaves:' => '🍃',
':fallen_leaf:' => '🍂', ':herb:' => '🌿', ':mushroom:' => '🍄', ':cactus:' => '🌵',
':palm_tree:' => '🌴', ':evergreen_tree:' => '🌲', ':deciduous_tree:' => '🌳', ':chestnut:' => '🌰',
':seedling:' => '🌱', ':blossom:' => '🌼', ':ear_of_rice:' => '🌾', ':shell:' => '🐚',
':globe_with_meridians:' => '🌐', ':sun_with_face:' => '🌞', ':full_moon_with_face:' => '🌝', ':new_moon_with_face:' => '🌚',
':new_moon:' => '🌑', ':waxing_crescent_moon:' => '🌒', ':first_quarter_moon:' => '🌓', ':waxing_gibbous_moon:' => '🌔',
':full_moon:' => '🌕', ':waning_gibbous_moon:' => '🌖', ':last_quarter_moon:' => '🌗', ':waning_crescent_moon:' => '🌘',
':last_quarter_moon_with_face:' => '🌜', ':first_quarter_moon_with_face:' => '🌛', ':moon:' => '🌔', ':earth_africa:' => '🌍',
':earth_americas:' => '🌎', ':earth_asia:' => '🌏', ':volcano:' => '🌋', ':milky_way:' => '🌌',
':partly_sunny:' => '⛅️', ':bamboo:' => '🎍', ':gift_heart:' => '💝', ':dolls:' => '🎎',
':school_satchel:' => '🎒', ':mortar_board:' => '🎓', ':flags:' => '🎏', ':fireworks:' => '🎆',
':sparkler:' => '🎇', ':wind_chime:' => '🎐', ':rice_scene:' => '🎑', ':jack_o_lantern:' => '🎃',
':ghost:' => '👻', ':santa:' => '🎅', ':christmas_tree:' => '🎄', ':gift:' => '🎁',
':bell:' => '🔔', ':no_bell:' => '🔕', ':tanabata_tree:' => '🎋', ':tada:' => '🎉',
':confetti_ball:' => '🎊', ':balloon:' => '🎈', ':crystal_ball:' => '🔮', ':cd:' => '💿',
':dvd:' => '📀', ':floppy_disk:' => '💾', ':camera:' => '📷', ':video_camera:' => '📹',
':movie_camera:' => '🎥', ':computer:' => '💻', ':tv:' => '📺', ':iphone:' => '📱',
':phone:' => '☎️', ':telephone:' => '☎️', ':telephone_receiver:' => '📞', ':pager:' => '📟',
':fax:' => '📠', ':minidisc:' => '💽', ':vhs:' => '📼', ':sound:' => '🔉',
':speaker:' => '🔈', ':mute:' => '🔇', ':loudspeaker:' => '📢', ':mega:' => '📣',
':hourglass:' => '⌛️', ':hourglass_flowing_sand:' => '⏳', ':alarm_clock:' => '⏰', ':watch:' => '⌚️',
':radio:' => '📻', ':satellite:' => '📡', ':loop:' => '➿', ':mag:' => '🔍',
':mag_right:' => '🔎', ':unlock:' => '🔓', ':lock:' => '🔒', ':lock_with_ink_pen:' => '🔏',
':closed_lock_with_key:' => '🔐', ':key:' => '🔑', ':bulb:' => '💡', ':flashlight:' => '🔦',
':high_brightness:' => '🔆', ':low_brightness:' => '🔅', ':electric_plug:' => '🔌', ':battery:' => '🔋',
':calling:' => '📲', ':email:' => '✉️', ':mailbox:' => '📫', ':postbox:' => '📮',
':bath:' => '🛀', ':bathtub:' => '🛁', ':shower:' => '🚿', ':toilet:' => '🚽',
':wrench:' => '🔧', ':nut_and_bolt:' => '🔩', ':hammer:' => '🔨', ':seat:' => '💺',
':moneybag:' => '💰', ':yen:' => '💴', ':dollar:' => '💵', ':pound:' => '💷',
':euro:' => '💶', ':credit_card:' => '💳', ':money_with_wings:' => '💸', ':e-mail:' => '📧',
':inbox_tray:' => '📥', ':outbox_tray:' => '📤', ':envelope:' => '✉️', ':incoming_envelope:' => '📨',
':postal_horn:' => '📯', ':mailbox_closed:' => '📪', ':mailbox_with_mail:' => '📬', ':mailbox_with_no_mail:' => '📭',
':door:' => '🚪', ':smoking:' => '🚬', ':bomb:' => '💣', ':gun:' => '🔫',
':hocho:' => '🔪', ':pill:' => '💊', ':syringe:' => '💉', ':page_facing_up:' => '📄',
':page_with_curl:' => '📃', ':bookmark_tabs:' => '📑', ':bar_chart:' => '📊', ':chart_with_upwards_trend:' => '📈',
':chart_with_downwards_trend:' => '📉', ':scroll:' => '📜', ':clipboard:' => '📋', ':calendar:' => '📆',
':date:' => '📅', ':card_index:' => '📇', ':file_folder:' => '📁', ':open_file_folder:' => '📂',
':scissors:' => '✂️', ':pushpin:' => '📌', ':paperclip:' => '📎', ':black_nib:' => '✒️',
':pencil2:' => '✏️', ':straight_ruler:' => '📏', ':triangular_ruler:' => '📐', ':closed_book:' => '📕',
':green_book:' => '📗', ':blue_book:' => '📘', ':orange_book:' => '📙', ':notebook:' => '📓',
':notebook_with_decorative_cover:' => '📔', ':ledger:' => '📒', ':books:' => '📚', ':bookmark:' => '🔖',
':name_badge:' => '📛', ':microscope:' => '🔬', ':telescope:' => '🔭', ':newspaper:' => '📰',
':football:' => '🏈', ':basketball:' => '🏀', ':soccer:' => '⚽️', ':baseball:' => '⚾️',
':tennis:' => '🎾', ':8ball:' => '🎱', ':rugby_football:' => '🏉', ':bowling:' => '🎳',
':golf:' => '⛳️', ':mountain_bicyclist:' => '🚵', ':bicyclist:' => '🚴', ':horse_racing:' => '🏇',
':snowboarder:' => '🏂', ':swimmer:' => '🏊', ':surfer:' => '🏄', ':ski:' => '🎿',
':spades:' => '♠️', ':hearts:' => '♥️', ':clubs:' => '♣️', ':diamonds:' => '♦️',
':gem:' => '💎', ':ring:' => '💍', ':trophy:' => '🏆', ':musical_score:' => '🎼',
':musical_keyboard:' => '🎹', ':violin:' => '🎻', ':space_invader:' => '👾', ':video_game:' => '🎮',
':black_joker:' => '🃏', ':flower_playing_cards:' => '🎴', ':game_die:' => '🎲', ':dart:' => '🎯',
':mahjong:' => '🀄️', ':clapper:' => '🎬', ':memo:' => '📝', ':pencil:' => '📝',
':book:' => '📖', ':art:' => '🎨', ':microphone:' => '🎤', ':headphones:' => '🎧',
':trumpet:' => '🎺', ':saxophone:' => '🎷', ':guitar:' => '🎸', ':shoe:' => '👞',
':sandal:' => '👡', ':high_heel:' => '👠', ':lipstick:' => '💄', ':boot:' => '👢',
':shirt:' => '👕', ':tshirt:' => '👕', ':necktie:' => '👔', ':womans_clothes:' => '👚',
':dress:' => '👗', ':running_shirt_with_sash:' => '🎽', ':jeans:' => '👖', ':kimono:' => '👘',
':bikini:' => '👙', ':ribbon:' => '🎀', ':tophat:' => '🎩', ':crown:' => '👑',
':womans_hat:' => '👒', ':mans_shoe:' => '👞', ':closed_umbrella:' => '🌂', ':briefcase:' => '💼',
':handbag:' => '👜', ':pouch:' => '👝', ':purse:' => '👛', ':eyeglasses:' => '👓',
':fishing_pole_and_fish:' => '🎣', ':coffee:' => '☕️', ':tea:' => '🍵', ':sake:' => '🍶',
':baby_bottle:' => '🍼', ':beer:' => '🍺', ':beers:' => '🍻', ':cocktail:' => '🍸',
':tropical_drink:' => '🍹', ':wine_glass:' => '🍷', ':fork_and_knife:' => '🍴', ':pizza:' => '🍕',
':hamburger:' => '🍔', ':fries:' => '🍟', ':poultry_leg:' => '🍗', ':meat_on_bone:' => '🍖',
':spaghetti:' => '🍝', ':curry:' => '🍛', ':fried_shrimp:' => '🍤', ':bento:' => '🍱',
':sushi:' => '🍣', ':fish_cake:' => '🍥', ':rice_ball:' => '🍙', ':rice_cracker:' => '🍘',
':rice:' => '🍚', ':ramen:' => '🍜', ':stew:' => '🍲', ':oden:' => '🍢',
':dango:' => '🍡', ':egg:' => '🥚', ':bread:' => '🍞', ':doughnut:' => '🍩',
':custard:' => '🍮', ':icecream:' => '🍦', ':ice_cream:' => '🍨', ':shaved_ice:' => '🍧',
':birthday:' => '🎂', ':cake:' => '🍰', ':cookie:' => '🍪', ':chocolate_bar:' => '🍫',
':candy:' => '🍬', ':lollipop:' => '🍭', ':honey_pot:' => '🍯', ':apple:' => '🍎',
':green_apple:' => '🍏', ':tangerine:' => '🍊', ':lemon:' => '🍋', ':cherries:' => '🍒',
':grapes:' => '🍇', ':watermelon:' => '🍉', ':strawberry:' => '🍓', ':peach:' => '🍑',
':melon:' => '🍈', ':banana:' => '🍌', ':pear:' => '🍐', ':pineapple:' => '🍍',
':sweet_potato:' => '🍠', ':eggplant:' => '🍆', ':tomato:' => '🍅', ':corn:' => '🌽',
':house:' => '🏠', ':house_with_garden:' => '🏡', ':school:' => '🏫', ':office:' => '🏢',
':post_office:' => '🏣', ':hospital:' => '🏥', ':bank:' => '🏦', ':convenience_store:' => '🏪',
':love_hotel:' => '🏩', ':hotel:' => '🏨', ':wedding:' => '💒', ':church:' => '⛪️',
':department_store:' => '🏬', ':european_post_office:' => '🏤', ':city_sunrise:' => '🌇', ':city_sunset:' => '🌆',
':japanese_castle:' => '🏯', ':european_castle:' => '🏰', ':tent:' => '⛺️', ':factory:' => '🏭',
':tokyo_tower:' => '🗼', ':japan:' => '🗾', ':mount_fuji:' => '🗻', ':sunrise_over_mountains:' => '🌄',
':sunrise:' => '🌅', ':stars:' => '🌠', ':statue_of_liberty:' => '🗽', ':bridge_at_night:' => '🌉',
':carousel_horse:' => '🎠', ':rainbow:' => '🌈', ':ferris_wheel:' => '🎡', ':fountain:' => '⛲️',
':roller_coaster:' => '🎢', ':ship:' => '🚢', ':speedboat:' => '🚤', ':boat:' => '⛵️',
':sailboat:' => '⛵️', ':rowboat:' => '🚣', ':anchor:' => '⚓️', ':rocket:' => '🚀',
':airplane:' => '✈️', ':helicopter:' => '🚁', ':steam_locomotive:' => '🚂', ':tram:' => '🚊',
':mountain_railway:' => '🚞', ':bike:' => '🚲', ':aerial_tramway:' => '🚡', ':suspension_railway:' => '🚟',
':mountain_cableway:' => '🚠', ':tractor:' => '🚜', ':blue_car:' => '🚙', ':oncoming_automobile:' => '🚘',
':car:' => '🚗', ':red_car:' => '🚗', ':taxi:' => '🚕', ':oncoming_taxi:' => '🚖',
':articulated_lorry:' => '🚛', ':bus:' => '🚌', ':oncoming_bus:' => '🚍', ':rotating_light:' => '🚨',
':police_car:' => '🚓', ':oncoming_police_car:' => '🚔', ':fire_engine:' => '🚒', ':ambulance:' => '🚑',
':minibus:' => '🚐', ':truck:' => '🚚', ':train:' => '🚋', ':station:' => '🚉',
':train2:' => '🚆', ':bullettrain_front:' => '🚅', ':bullettrain_side:' => '🚄', ':light_rail:' => '🚈',
':monorail:' => '🚝', ':railway_car:' => '🚃', ':trolleybus:' => '🚎', ':ticket:' => '🎫',
':fuelpump:' => '⛽️', ':vertical_traffic_light:' => '🚦', ':traffic_light:' => '🚥', ':warning:' => '⚠️',
':construction:' => '🚧', ':beginner:' => '🔰', ':atm:' => '🏧', ':slot_machine:' => '🎰',
':busstop:' => '🚏', ':barber:' => '💈', ':hotsprings:' => '♨️', ':checkered_flag:' => '🏁',
':crossed_flags:' => '🎌', ':izakaya_lantern:' => '🏮', ':moyai:' => '🗿', ':circus_tent:' => '🎪',
':performing_arts:' => '🎭', ':round_pushpin:' => '📍', ':triangular_flag_on_post:' => '🚩', ':jp:' => '🇯🇵',
':kr:' => '🇰🇷', ':cn:' => '🇨🇳', ':us:' => '🇺🇸', ':fr:' => '🇫🇷',
':es:' => '🇪🇸', ':it:' => '🇮🇹', ':ru:' => '🇷🇺', ':gb:' => '🇬🇧',
':uk:' => '🇬🇧', ':de:' => '🇩🇪', ':one:' => '1⃣', ':two:' => '2⃣',
':three:' => '3⃣', ':four:' => '4⃣', ':five:' => '5⃣', ':six:' => '6⃣',
':seven:' => '7⃣', ':eight:' => '8⃣', ':nine:' => '9⃣', ':keycap_ten:' => '🔟',
':1234:' => '🔢', ':zero:' => '0⃣', ':hash:' => '#️⃣', ':symbols:' => '🔣',
':arrow_backward:' => '◀️', ':arrow_down:' => '⬇️', ':arrow_forward:' => '▶️', ':arrow_left:' => '⬅️',
':capital_abcd:' => '🔠', ':abcd:' => '🔡', ':abc:' => '🔤', ':arrow_lower_left:' => '↙️',
':arrow_lower_right:' => '↘️', ':arrow_right:' => '➡️', ':arrow_up:' => '⬆️', ':arrow_upper_left:' => '↖️',
':arrow_upper_right:' => '↗️', ':arrow_double_down:' => '⏬', ':arrow_double_up:' => '⏫', ':arrow_down_small:' => '🔽',
':arrow_heading_down:' => '⤵️', ':arrow_heading_up:' => '⤴️', ':leftwards_arrow_with_hook:' => '↩️', ':arrow_right_hook:' => '↪️',
':left_right_arrow:' => '↔️', ':arrow_up_down:' => '↕️', ':arrow_up_small:' => '🔼', ':arrows_clockwise:' => '🔃',
':arrows_counterclockwise:' => '🔄', ':rewind:' => '⏪', ':fast_forward:' => '⏩', ':information_source:' => '',
':ok:' => '🆗', ':twisted_rightwards_arrows:' => '🔀', ':repeat:' => '🔁', ':repeat_one:' => '🔂',
':new:' => '🆕', ':top:' => '🔝', ':up:' => '🆙', ':cool:' => '🆒',
':free:' => '🆓', ':ng:' => '🆖', ':cinema:' => '🎦', ':koko:' => '🈁',
':signal_strength:' => '📶', ':u5272:' => '🈹', ':u5408:' => '🈴', ':u55b6:' => '🈺',
':u6307:' => '🈯️', ':u6708:' => '🈷️', ':u6709:' => '🈶', ':u6e80:' => '🈵',
':u7121:' => '🈚️', ':u7533:' => '🈸', ':u7a7a:' => '🈳', ':u7981:' => '🈲',
':sa:' => '🈂️', ':restroom:' => '🚻', ':mens:' => '🚹', ':womens:' => '🚺',
':baby_symbol:' => '🚼', ':no_smoking:' => '🚭', ':parking:' => '🅿️', ':wheelchair:' => '♿️',
':metro:' => '🚇', ':baggage_claim:' => '🛄', ':accept:' => '🉑', ':wc:' => '🚾',
':potable_water:' => '🚰', ':put_litter_in_its_place:' => '🚮', ':secret:' => '㊙️', ':congratulations:' => '㊗️',
':m:' => 'Ⓜ️', ':passport_control:' => '🛂', ':left_luggage:' => '🛅', ':customs:' => '🛃',
':ideograph_advantage:' => '🉐', ':cl:' => '🆑', ':sos:' => '🆘', ':id:' => '🆔',
':no_entry_sign:' => '🚫', ':underage:' => '🔞', ':no_mobile_phones:' => '📵', ':do_not_litter:' => '🚯',
':non-potable_water:' => '🚱', ':no_bicycles:' => '🚳', ':no_pedestrians:' => '🚷', ':children_crossing:' => '🚸',
':no_entry:' => '⛔️', ':eight_spoked_asterisk:' => '✳️', ':eight_pointed_black_star:' => '✴️', ':heart_decoration:' => '💟',
':vs:' => '🆚', ':vibration_mode:' => '📳', ':mobile_phone_off:' => '📴', ':chart:' => '💹',
':currency_exchange:' => '💱', ':aries:' => '♈️', ':taurus:' => '♉️', ':gemini:' => '♊️',
':cancer:' => '♋️', ':leo:' => '♌️', ':virgo:' => '♍️', ':libra:' => '♎️',
':scorpius:' => '♏️', ':sagittarius:' => '♐️', ':capricorn:' => '♑️', ':aquarius:' => '♒️',
':pisces:' => '♓️', ':ophiuchus:' => '⛎', ':six_pointed_star:' => '🔯', ':negative_squared_cross_mark:' => '❎',
':a:' => '🅰️', ':b:' => '🅱️', ':ab:' => '🆎', ':o2:' => '🅾️',
':diamond_shape_with_a_dot_inside:' => '💠', ':recycle:' => '♻️', ':end:' => '🔚', ':on:' => '🔛',
':soon:' => '🔜', ':clock1:' => '🕐', ':clock130:' => '🕜', ':clock10:' => '🕙',
':clock1030:' => '🕥', ':clock11:' => '🕚', ':clock1130:' => '🕦', ':clock12:' => '🕛',
':clock1230:' => '🕧', ':clock2:' => '🕑', ':clock230:' => '🕝', ':clock3:' => '🕒',
':clock330:' => '🕞', ':clock4:' => '🕓', ':clock430:' => '🕟', ':clock5:' => '🕔',
':clock530:' => '🕠', ':clock6:' => '🕕', ':clock630:' => '🕡', ':clock7:' => '🕖',
':clock730:' => '🕢', ':clock8:' => '🕗', ':clock830:' => '🕣', ':clock9:' => '🕘',
':clock930:' => '🕤', ':heavy_dollar_sign:' => '💲', ':copyright:' => '©️', ':registered:' => '®️',
':tm:' => '™️', ':x:' => '❌', ':heavy_exclamation_mark:' => '❗️', ':bangbang:' => '‼️',
':interrobang:' => '⁉️', ':o:' => '⭕️', ':heavy_multiplication_x:' => '✖️', ':heavy_plus_sign:' => '',
':heavy_minus_sign:' => '', ':heavy_division_sign:' => '➗', ':white_flower:' => '💮', ':100:' => '💯',
':heavy_check_mark:' => '✔️', ':ballot_box_with_check:' => '☑️', ':radio_button:' => '🔘', ':link:' => '🔗',
':curly_loop:' => '➰', ':wavy_dash:' => '〰️', ':part_alternation_mark:' => '〽️', ':trident:' => '🔱',
':white_check_mark:' => '✅', ':black_square_button:' => '🔲', ':white_square_button:' => '🔳', ':black_circle:' => '⚫️',
':white_circle:' => '⚪️', ':red_circle:' => '🔴', ':large_blue_circle:' => '🔵', ':large_blue_diamond:' => '🔷',
':large_orange_diamond:' => '🔶', ':small_blue_diamond:' => '🔹', ':small_orange_diamond:' => '🔸', ':small_red_triangle:' => '🔺',
':small_red_triangle_down:' => '🔻', ':black_small_square:' => '▪️', ':black_medium_small_square:' => '◾', ':black_medium_square:' => '◼️',
':black_large_square:' => '⬛', ':white_small_square:' => '▫️', ':white_medium_small_square:' => '◽', ':white_medium_square:' => '◻️',
':white_large_square:' => '⬜',
];
return [
'extent' => \strlen($matches[0]),
'element' => [
'text' => \str_replace(\array_keys($emojiMap), $emojiMap, $matches[0]),
],
];
}
/**
* Handle marks
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineMark(array $excerpt) : ?array
{
if (!\str_starts_with($excerpt['text'], '==')
|| \preg_match('/^(==)([^=]*?)(==)/', $excerpt['text'], $matches) !== 1
) {
return null;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'mark',
'text' => $matches[2],
],
];
}
/**
* Handle marks
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineSpoiler(array $excerpt) : ?array
{
if (!\str_starts_with($excerpt['text'], '>!')
|| \preg_match('/^>!(.*?)!</us', $excerpt['text'], $matches) !== 1
) {
return null;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'span',
'attributes' => [
'class' => 'spoiler',
],
'elements' => [
[
'name' => 'input',
'attributes' => [
'type' => 'checkbox',
],
],
[
'name' => 'span',
'text' => $matches[1],
],
],
],
];
}
/**
* Handle keystrokes
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineKeystrokes(array $excerpt) : ?array
{
if (!\str_starts_with($excerpt['text'], '[[')
|| \preg_match('/^(?<!\[)(?:\[\[([^\[\]]*|[\[\]])\]\])(?!\])/s', $excerpt['text'], $matches) !== 1
) {
return null;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'kbd',
'text' => $matches[1],
],
];
}
/**
* Handle embedding
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineEmbedding(array $excerpt) : ?array
{
$video = false;
$audio = false;
if (!($this->options['embedding'] ?? false)
|| (!\str_starts_with($excerpt['text'], '[video') && !\str_starts_with($excerpt['text'], '[audio'))
|| (!($video = (\preg_match('/\[video.*src="([^"]*)".*\]/', $excerpt['text'], $matches) === 1))
&& !($audio = (\preg_match('/\[audio.*src="([^"]*)".*\]/', $excerpt['text'], $matches) === 1)))
) {
return null;
}
$url = $matches[1];
if ($video) {
$type = '';
$needles = ['youtube', 'vimeo', 'dailymotion'];
foreach ($needles as $needle) {
if (\strpos($url, $needle) !== false) {
$type = $needle;
}
}
switch ($type) {
case 'youtube':
$element = 'iframe';
$attributes = [
'src' => \preg_replace('/.*\?v=([^\&\]]*).*/', 'https://www.youtube.com/embed/$1', $url),
'frameborder' => '0',
'allow' => 'autoplay',
'allowfullscreen' => '',
'sandbox' => 'allow-same-origin allow-scripts allow-forms',
];
break;
case 'vimeo':
$element = 'iframe';
$attributes = [
'src' => \preg_replace('/(?:https?:\/\/(?:[\w]{3}\.|player\.)*vimeo\.com(?:[\/\w:]*(?:\/videos)?)?\/([0-9]+)[^\s]*)/', 'https://player.vimeo.com/video/$1', $url),
'frameborder' => '0',
'allow' => 'autoplay',
'allowfullscreen' => '',
'sandbox' => 'allow-same-origin allow-scripts allow-forms',
];
break;
case 'dailymotion':
$element = 'iframe';
$attributes = [
'src' => $url,
'frameborder' => '0',
'allow' => 'autoplay',
'allowfullscreen' => '',
'sandbox' => 'allow-same-origin allow-scripts allow-forms',
];
break;
default:
$element = 'video';
$attributes = [
'src' => UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $url)),
'controls' => '',
];
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => $element,
'text' => $matches[1],
'attributes' => $attributes,
],
];
} elseif ($audio) {
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'audio',
'text' => $matches[1],
'attributes' => [
'src' => UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $url)),
'controls' => '',
],
],
];
}
return null;
}
/**
* Handle map
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineMap(array $excerpt) : ?array
{
if (!($this->options['map'] ?? false)
|| !\str_starts_with($excerpt['text'], '[map')
|| (\preg_match('/\[map(?:\s+(?:name="([^"]+)"|country="([^"]+)"|city="([^"]+)"|zip="([^"]+)"|address="([^"]+)"|lat="([^"]+)"|lon="([^"]+)")){0,7}\]/', $excerpt['text'], $matches) !== 1)
) {
return null;
}
$name = $matches[1];
$country = $matches[2];
$city = $matches[3];
$zip = $matches[4];
$address = $matches[5];
$lat = $matches[6];
$lon = $matches[7];
if ($lat === '' || $lon === '') {
[$lat, $lon] = \phpOMS\Api\Geocoding\Nominatim::geocoding($country, $city, $address, $zip);
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'div',
'text' => '',
'attributes' => [
'id' => 'i' . \bin2hex(\random_bytes(4)),
'class' => 'map',
'data-lat' => $lat,
'data-lon' => $lon,
],
],
];
}
/**
* Handle address
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineAddress(array $excerpt) : ?array
{
if (!($this->options['address'] ?? false)
|| !\str_starts_with($excerpt['text'], '[addr')
|| (\preg_match('/\[addr(?:\s+(?:name="([^"]+)"|country="([^"]+)"|city="([^"]+)"|zip="([^"]+)"|address="([^"]+)")){0,5}\]/', $excerpt['text'], $matches) !== 1)
) {
return null;
}
$name = $matches[1];
$country = $matches[2];
$city = $matches[3];
$zip = $matches[4];
$address = $matches[5];
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'div',
//'text' => '',
'attributes' => [
'class' => 'addressWidget',
],
'elements' => [
[
'name' => 'span',
'text' => $name,
'attributes' => ['class' => 'addressWidget-name'],
],
[
'name' => 'span',
'text' => $address,
'attributes' => ['class' => 'addressWidget-address'],
],
[
'name' => 'span',
'text' => $zip,
'attributes' => ['class' => 'addressWidget-zip'],
],
[
'name' => 'span',
'text' => $city,
'attributes' => ['class' => 'addressWidget-city'],
],
[
'name' => 'span',
'text' => $country,
'attributes' => ['class' => 'addressWidget-country'],
],
],
],
];
}
/**
* Handle contact
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineContact(array $excerpt) : ?array
{
if (!($this->options['contact'] ?? false)
|| !\str_starts_with($excerpt['text'], '[contact')
|| (\preg_match('/\[contact.*?([a-zA-Z]+)="(.*?)"\]/', $excerpt['text'], $matches) !== 1)
) {
return null;
}
$src = '';
switch ($matches[1]) {
case 'email':
$src = 'Resources/icons/company/email.svg';
break;
case 'phone':
$src = 'Resources/icons/company/phone.svg';
break;
case 'twitter':
$src = 'Resources/icons/company/twitter.svg';
break;
case 'instagram':
$src = 'Resources/icons/company/instagram.svg';
break;
case 'discord':
$src = 'Resources/icons/company/discord.svg';
break;
case 'slack':
$src = 'Resources/icons/company/slack.svg';
break;
case 'teams':
$src = 'Resources/icons/company/teams.svg';
break;
case 'facebook':
$src = 'Resources/icons/company/facebook.svg';
break;
case 'youtube':
$src = 'Resources/icons/company/youtube.svg';
break;
case 'paypal':
$src = 'Resources/icons/company/paypal.svg';
break;
case 'linkedin':
$src = 'Resources/icons/company/linkedin.svg';
break;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'a',
//'text' => '',
'attributes' => [
'class' => 'contactWidget',
'href' => '',
],
'elements' => [
[
'name' => 'img',
'attributes' => [
'class' => 'contactWidget-icon',
'src' => $src,
],
],
[
'name' => 'span',
'text' => $matches[2],
'attributes' => ['class' => 'contactWidget-contact'],
],
],
],
];
}
/**
* Handle progress
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineProgress(array $excerpt) : ?array
{
if (!($this->options['progress'] ?? false)
|| !\str_starts_with($excerpt['text'], '[progress')
|| (\preg_match('/\[progress(?:\s+(?:type="([^"]+)"|percent="([^"]+)"|value="([^"]+)")){0,3}\]/', $excerpt['text'], $matches) !== 1)
) {
return null;
}
// $type = empty($matches[1]) ? 'meter' : $matches[1];
$percent = empty($matches[2]) ? $matches[3] : $matches[2];
$value = empty($matches[3]) ? $matches[2] : $matches[3];
if ($percent === ''
|| $value === ''
) {
return null;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'progress',
'text' => '',
'attributes' => [
'value' => $value,
'max' => '100',
],
],
];
}
/**
* Handle super script
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineSuperscript(array $excerpt) : ?array
{
if (\preg_match('/(?:\^(?!\^)([^\^ ]*)\^(?!\^))/', $excerpt['text'], $matches) !== 1) {
return null;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'sup',
'text' => $matches[1],
'function' => 'lineElements',
],
];
}
/**
* Handle sub script
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineSubscript(array $excerpt) : ?array
{
if (\preg_match('/(?:~(?!~)([^~ ]*)~(?!~))/', $excerpt['text'], $matches) !== 1) {
return null;
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'sub',
'text' => $matches[1],
'function' => 'lineElements',
],
];
}
/**
* Handle typographer
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineTypographer(array $excerpt) : ?array
{
if (\preg_match('/\+-|\(p\)|\(tm\)|\(r\)|\(c\)|\.{2,}|\!\.{3,}|\?\.{3,}/i', $excerpt['text'], $matches) !== 1) {
return null;
}
$substitutions = [
'/\(c\)/i' => '&copy;',
'/\(r\)/i' => '&reg;',
'/\(tm\)/i' => '&trade;',
'/\(p\)/i' => '&para;',
'/\+-/i' => '&plusmn;',
'/\.{4,}|\.{2}/i' => '...',
'/\!\.{3,}/i' => '!..',
'/\?\.{3,}/i' => '?..',
];
return [
'extent' => \strlen($matches[0]),
'element' => [
'rawHtml' => \preg_replace(\array_keys($substitutions), \array_values($substitutions), $matches[0]),
],
];
}
/**
* Handle math
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineMath(array $excerpt) : ?array
{
$matchSingleDollar = $this->options['math']['single_dollar'] ?? false;
if ($matchSingleDollar) {
// Match single dollar - experimental
if (\preg_match('/^(?<!\\\\)((?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)|(?<!\\\\\()\\\\\((.*?)(?<!\\\\\()\\\\\)(?!\\\\\)))/s', $excerpt['text'], $matches)) {
$mathMatch = $matches[0];
}
} elseif (\preg_match('/^(?<!\\\\\()\\\\\((.*?)(?<!\\\\\()\\\\\)(?!\\\\\))/s', $excerpt['text'], $matches)) {
$mathMatch = $matches[0];
}
if (!isset($mathMatch)) {
return null;
}
return [
'extent' => \strlen($mathMatch),
'element' => [
'text' => $mathMatch,
],
];
}
/**
* Handle escape sequence
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineEscapeSequence(array $excerpt) : ?array
{
if (!isset($excerpt['text'][1])
|| !\in_array($excerpt['text'][1], $this->specialCharacters)
) {
return null;
}
$state = $this->options['math'] ?? false;
if (!$state
|| !\preg_match('/^(?<!\\\\)(?<!\\\\\()\\\\\((.{2,}?)(?<!\\\\\()\\\\\)(?!\\\\\))/s', $excerpt['text'])
) {
return [
'extent' => 2,
'element' => [
'rawHtml' => $excerpt['text'][1],
],
];
}
return null;
}
/**
* Handle block footnote
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockFootnote(array $line, ?array $_ = null) : ?array
{
return ($this->options['footnotes'] ?? true)
&& \preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $line['text'], $matches) == 1
? [
'label' => $matches[1],
'text' => $matches[2],
'hidden' => true,
]
: null;
}
/**
* Handle block definition list
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockDefinitionList(array $line, ?array $block = null) : ?array
{
if (!($this->options['definition_lists'] ?? true)
|| $block === null
|| $block['type'] !== 'Paragraph'
) {
return null;
}
$element = [
'name' => 'dl',
'elements' => [],
];
$terms = \explode("\n", $block['element']['handler']['argument']);
foreach ($terms as $term) {
$element['elements'][] = [
'name' => 'dt',
'handler' => [
'function' => 'lineElements',
'argument' => $term,
'destination' => 'elements',
],
];
}
$block['element'] = $element;
return $this->addDdElement($line, $block);
}
/**
* Handle block code
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockCode(array $line, ?array $block = null) : ?array
{
if (!($this->options['code']['blocks'] ?? true)
|| !($this->options['code'] ?? true)
|| ($block !== null && $block['type'] === 'Paragraph' && !isset($block['interrupted']))
|| $line['indent'] < 4
) {
return null;
}
return [
'element' => [
'name' => 'pre',
'element' => [
'name' => 'code',
'text' => \substr($line['body'], 4),
],
],
];
}
/**
* Handle block comment
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockComment(array $line, ?array $_ = null) : ?array
{
if (!($this->options['comments'] ?? true)
|| $this->markupEscaped || $this->safeMode
|| !\str_starts_with($line['text'], '<!--')
) {
return null;
}
$block = [
'element' => [
'rawHtml' => $line['body'],
'autobreak' => true,
],
];
if (\strpos($line['text'], '-->') !== false) {
$block['closed'] = true;
}
return $block;
}
/**
* Handle block comment
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockHeader(array $line, ?array $_ = null) : ?array
{
if (!($this->options['headings'] ?? true)) {
return null;
}
$level = \strspn($line['text'], '#');
if ($level > 6) {
return null;
}
$text = \trim($line['text'], '#');
if ($this->strictMode && isset($text[0]) && $text[0] !== ' ') {
return null;
}
$text = \trim($text, ' ');
$block = [
'element' => [
'name' => 'h' . $level,
'handler' => [
'function' => 'lineElements',
'argument' => $text,
'destination' => 'elements',
],
],
];
if (\preg_match('/[ #]*{(' . $this->regexAttribute . '+)}[ ]*$/', $block['element']['handler']['argument'], $matches, \PREG_OFFSET_CAPTURE)) {
$attributeString = $matches[1][0];
$block['element']['attributes'] = $this->parseAttributeData($attributeString);
$block['element']['handler']['argument'] = \substr($block['element']['handler']['argument'], 0, (int) $matches[0][1]);
}
// Get the text of the heading
if (isset($block['element']['handler']['argument'])) {
$text = $block['element']['handler']['argument'];
}
// Get the heading level. Levels are h1, h2, ..., h6
$level = $block['element']['name'];
$headersAllowed = $this->options['headings']['allowed'] ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
if (!\in_array($level, $headersAllowed)) {
return null;
}
// Checks if auto generated anchors is allowed
$autoAnchors = $this->options['headings']['auto_anchors'] ?? true;
$id = $block['element']['attributes']['id'] ?? ($autoAnchors ? $this->createAnchorID($text) : null);
// Set attributes to head tags
$block['element']['attributes']['id'] = $id;
$tocHeaders = $this->options['toc']['headings'] ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
// Check if level are defined as a heading
if (\in_array($level, $tocHeaders)) {
// Add/stores the heading element info to the ToC list
$this->setContentsList([
'text' => $text,
'id' => $id,
'level' => $level,
]);
}
return $block;
}
/**
* Handle block list
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $current Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockList(array $line, ?array $current = null) : ?array
{
if (!($this->options['lists'] ?? true)) {
return null;
}
[$name, $pattern] = $line['text'][0] <= '-' ? ['ul', '[*+-]'] : ['ol', '[0-9]{1,9}+[.\)]'];
if (\preg_match('/^(' . $pattern . '([ ]++|$))(.*+)/', $line['text'], $matches) !== 1) {
return null;
}
$contentIndent = \strlen($matches[2]);
if ($contentIndent >= 5) {
--$contentIndent;
$matches[1] = \substr($matches[1], 0, -$contentIndent);
$matches[3] = \str_repeat(' ', $contentIndent) . $matches[3];
} elseif ($contentIndent === 0) {
$matches[1] .= ' ';
}
$markerWithoutWhitespace = \strstr($matches[1], ' ', true);
if ($markerWithoutWhitespace === false) {
$markerWithoutWhitespace = $matches[1];
}
if ($name !== 'ul') {
$markerWithoutWhitespace = \substr($markerWithoutWhitespace, -1);
if ($markerWithoutWhitespace === false) {
$markerWithoutWhitespace = $matches[1];
}
}
$block = [
'indent' => $line['indent'],
'pattern' => $pattern,
'data' => [
'type' => $name,
'marker' => $matches[1],
'markerType' => $markerWithoutWhitespace,
],
'element' => [
'name' => $name,
'elements' => [],
],
];
$block['data']['markerTypeRegex'] = \preg_quote($block['data']['markerType'], '/');
if ($name === 'ol') {
$tmp = \strstr($matches[1], $block['data']['markerType'], true);
if ($tmp === false) {
$tmp = $matches[1];
}
$listStart = \ltrim($tmp, '0') ?: '0';
if ($listStart !== '0') {
if (isset($current)
&& $current['type'] === 'Paragraph'
&& !isset($current['interrupted'])
) {
return null;
}
$block['element']['attributes'] = ['start' => $listStart];
}
}
$block['li'] = [
'name' => 'li',
'handler' => [
'function' => 'li',
'argument' => empty($matches[3]) ? [] : [$matches[3]],
'destination' => 'elements',
],
];
$block['element']['elements'][] = &$block['li'];
return $block;
}
/**
* Handle block quote
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockQuote(array $line, ?array $_ = null) : ?array
{
if (!($this->options['qoutes'] ?? true)
|| \preg_match('/^>[ ]?+(.*+)/', $line['text'], $matches) !== 1
) {
return null;
}
return [
'element' => [
'name' => 'blockquote',
'handler' => [
'function' => 'linesElements',
'argument' => (array) $matches[1],
'destination' => 'elements',
],
],
];
}
/**
* Handle block rule
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockRule(array $line, ?array $_ = null) : ?array
{
if (!($this->options['thematic_breaks'] ?? true)) {
return null;
}
$marker = $line['text'][0];
if (\substr_count($line['text'], $marker) >= 3 && \rtrim($line['text'], " {$marker}") === '') {
return [
'element' => [
'name' => 'hr',
],
];
}
return null;
}
/**
* Handle block header
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockSetextHeader(array $line, ?array $block = null) : ?array
{
if (!($this->options['headings'] ?? true)
|| $block === null
|| $block['type'] !== 'Paragraph'
|| isset($block['interrupted'])
) {
return null;
}
if ($line['indent'] < 4 && \rtrim(\rtrim($line['text'], ' '), $line['text'][0]) === '') {
$block['element']['name'] = $line['text'][0] === '=' ? 'h1' : 'h2';
}
if (\preg_match('/[ ]*{(' . $this->regexAttribute . '+)}[ ]*$/', $block['element']['handler']['argument'], $matches, \PREG_OFFSET_CAPTURE)) {
$attributeString = $matches[1][0];
$block['element']['attributes'] = $this->parseAttributeData($attributeString);
$block['element']['handler']['argument'] = \substr($block['element']['handler']['argument'], 0, (int) $matches[0][1]);
}
// Get the text of the heading
$text = null;
if (isset($block['element']['handler']['argument'])) {
$text = $block['element']['handler']['argument'];
}
// Get the heading level. Levels are h1, h2, ..., h6
$level = $block['element']['name'];
$headersAllowed = $this->options['headings']['allowed'] ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
if (!\in_array($level, $headersAllowed)) {
return null;
}
// Checks if auto generated anchors is allowed
$autoAnchors = $this->options['headings']['auto_anchors'] ?? true;
$id = $block['element']['attributes']['id'] ?? ($autoAnchors ? $this->createAnchorID($text) : null);
// Set attributes to head tags
$block['element']['attributes']['id'] = $id;
$headersAllowed = $this->options['headings']['allowed'] ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
// Check if level are defined as a heading
if (\in_array($level, $headersAllowed)) {
// Add/stores the heading element info to the ToC list
$this->setContentsList([
'text' => $text,
'id' => $id,
'level' => $level,
]);
}
return $block;
}
/**
* Handle block markup
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockMarkup(array $line, ?array $_ = null) : ?array
{
if (!($this->options['markup'] ?? true)
|| $this->markupEscaped || $this->safeMode
|| \preg_match('/^<(\w[\w-]*)(?:[ ]*' . $this->regexHtmlAttribute . ')*[ ]*(\/)?>/', $line['text'], $matches) !== 1
) {
return null;
}
$element = \strtolower($matches[1]);
if (\in_array($element, $this->textLevelElements)) {
return null;
}
$block = [
'name' => $matches[1],
'depth' => 0,
'element' => [
'rawHtml' => $line['text'],
'autobreak' => true,
],
];
$length = \strlen($matches[0]);
$remainder = \substr($line['text'], $length);
if (\trim($remainder) === '') {
if (isset($matches[2]) || \in_array($matches[1], $this->voidElements)) {
$block['closed'] = true;
$block['void'] = true;
}
} else {
if (isset($matches[2]) || \in_array($matches[1], $this->voidElements)) {
return null;
}
if (\preg_match('/<\/' . $matches[1] . '>[ ]*$/i', $remainder)) {
$block['closed'] = true;
}
}
return $block;
}
/**
* Handle block reference
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockReference(array $line, ?array $_ = null) : ?array
{
if (!($this->options['references'] ?? true)
|| \strpos($line['text'], ']') === false
|| \preg_match('/^\[(.+?)\]:[ ]*+<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $line['text'], $matches) !== 1
) {
return null;
}
$id = \strtolower($matches[1]);
$this->definitionData['Reference'][$id] = [
'url' => UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[2])),
'title' => isset($matches[3]) ? $matches[3] : null,
];
return [
'element' => [],
];
}
/**
* Handle block table
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockTable(array $line, ?array $block = null) : ?array
{
if (!($this->options['tables'] ?? true)
|| $block === null || $block['type'] !== 'Paragraph' || isset($block['interrupted'])
|| (\strpos($block['element']['handler']['argument'], '|') === false
&& \strpos($line['text'], '|') === false
&& \strpos($line['text'], ':') === false
|| \strpos($block['element']['handler']['argument'], "\n") !== false)
|| \rtrim($line['text'], ' -:|') !== ''
) {
return null;
}
$alignments = [];
$divider = $line['text'];
$divider = \trim($divider);
$divider = \trim($divider, '|');
$dividerCells = \explode('|', $divider);
foreach ($dividerCells as $dividerCell) {
$dividerCell = \trim($dividerCell);
if ($dividerCell === '') {
return null;
}
$alignment = null;
if ($dividerCell[0] === ':') {
$alignment = 'left';
}
if (\substr($dividerCell, - 1) === ':') {
$alignment = $alignment === 'left' ? 'center' : 'right';
}
$alignments [] = $alignment;
}
$headerElements = [];
$header = $block['element']['handler']['argument'];
$header = \trim($header);
$header = \trim($header, '|');
$headerCells = \explode('|', $header);
if (\count($headerCells) !== \count($alignments)) {
return null;
}
foreach ($headerCells as $index => $headerCell) {
$headerCell = \trim($headerCell);
$headerElement = [
'name' => 'th',
'handler' => [
'function' => 'lineElements',
'argument' => $headerCell,
'destination' => 'elements',
],
];
if (isset($alignments[$index])) {
$alignment = $alignments[$index];
$headerElement['attributes'] = [
'style' => "text-align: {$alignment};",
];
}
$headerElements[] = $headerElement;
}
$block = [
'alignments' => $alignments,
'identified' => true,
'element' => [
'name' => 'table',
'elements' => [],
],
];
$block['element']['elements'][] = [
'name' => 'thead',
];
$block['element']['elements'][] = [
'name' => 'tbody',
'elements' => [],
];
$block['element']['elements'][0]['elements'][] = [
'name' => 'tr',
'elements' => $headerElements,
];
return $block;
}
/**
* Handle block abbreviation
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockAbbreviation(array $line, ?array $_ = null) : ?array
{
if (!($this->options['abbreviations'] ?? true)) {
return null;
}
$allowCustomAbbr = $this->options['abbreviations']['allow_custom_abbr'] ?? true;
if (isset($this->options['abbreviations']['predefine'])) {
foreach ($this->options['abbreviations']['predefine'] as $abbreviations => $description) {
$this->definitionData['Abbreviation'][$abbreviations] = $description;
}
}
if (!$allowCustomAbbr
|| \preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $line['text'], $matches) !== 1
) {
return null;
}
$this->definitionData['Abbreviation'][$matches[1]] = $matches[2];
return [
'hidden' => true,
];
}
/**
* Handle block math
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockMath(array $line, ?array $_ = null) : ?array
{
$block = [
'element' => [
'text' => '',
],
];
if (\preg_match('/^(?<!\\\\)(\\\\\[)(?!.)$/', $line['text'])) {
$block['end'] = '\]';
return $block;
}
if (\preg_match('/^(?<!\\\\)(\$\$)(?!.)$/', $line['text'])) {
$block['end'] = '$$';
return $block;
}
return null;
}
/**
* Continue block math
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockMathContinue(array $line, array $block) : ?array
{
if (isset($block['complete'])) {
return null;
}
if (isset($block['interrupted'])) {
$block['element']['text'] .= \str_repeat("\n", $block['interrupted']);
unset($block['interrupted']);
}
if (\preg_match('/^(?<!\\\\)(\\\\\])$/', $line['text']) && $block['end'] === '\]') {
$block['complete'] = true;
$block['math'] = true;
$block['element']['text'] = '\\[' . $block['element']['text'] . '\\]';
return $block;
}
if (\preg_match('/^(?<!\\\\)(\$\$)$/', $line['text']) && $block['end'] === '$$') {
$block['complete'] = true;
$block['math'] = true;
$block['element']['text'] = '$$' . $block['element']['text'] . '$$';
return $block;
}
$block['element']['text'] .= "\n" . $line['body'];
return $block;
}
/**
* Complete block math
*
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function blockMathComplete(array $block) : array
{
return $block;
}
/**
* Continue block math
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockFencedCode(array $line, ?array $_ = null) : ?array
{
if (!($this->options['code']['blocks'] ?? true)
|| !($this->options['code'] ?? true)
) {
return null;
}
$marker = $line['text'][0];
$openerLength = \strspn($line['text'], $marker);
if ($openerLength < 3) {
return null;
}
$language = \trim(\preg_replace('/^`{3}([^\s]+)(.+)?/s', '$1', $line['text']) ?? '');
if (!($this->options['diagrams'] ?? true)
|| !\in_array($language, ['mermaid', 'chartjs', 'tuichart'])
) {
// Is code block
$element = [
'name' => 'code',
'text' => '',
];
if ($language !== '```' && !empty($language)) {
$element['attributes'] = ['class' => "language-{$language}"];
}
return [
'char' => $marker,
'openerLength' => $openerLength,
'element' => [
'name' => 'pre',
'element' => $element,
],
];
} elseif (\strtolower($language) === 'chartjs') {
// Chart.js https://www.chartjs.org/
$element = [
'text' => '',
];
return [
'char' => $marker,
'openerLength' => $openerLength,
'element' => [
'element' => $element,
'name' => 'canvas',
'attributes' => [
'class' => 'chartjs',
],
],
];
} elseif (\in_array(\strtolower($language), ['mermaid', 'tuichart'])) {
// Mermaid.js https://mermaidjs.github.io
// TUI.chart https://github.com/nhn/tui.chart
$element = [
'text' => '',
];
return [
'char' => $marker,
'openerLength' => $openerLength,
'element' => [
'element' => $element,
'name' => 'div',
'attributes' => [
'class' => \strtolower($language),
],
],
];
}
return null;
}
/**
* Continue block spoiler
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockSpoiler(array $line, ?array $_ = null) : ?array
{
if (!($this->options['code']['blocks'] ?? true)
|| !($this->options['code'] ?? true)
) {
return null;
}
$marker = $line['text'][0];
$openerLength = \strspn($line['text'], $marker);
if ($openerLength < 3) {
return null;
}
$summary = \trim(\preg_replace('/^\?{3}(.+)?/s', '$1', $line['text']) ?? '');
$infostring = \trim(\substr($line['text'], $openerLength), "\t ");
if (\strpos($infostring, '?') !== false) {
return null;
}
// @performance Optimize away the child <span> element for spoilers (if reasonable)
// https://github.com/Karaka-Management/phpOMS/issues/367
return [
'char' => $marker,
'openerLength' => $openerLength,
'element' => [
'name' => 'details',
'element' => [
'text' => '',
'elements' => [
[
'name' => 'summary',
'text' => $summary,
],
[
'name' => 'span',
'text' => '',
],
],
],
],
];
}
/**
* Complete block table
*
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockTableComplete(array $block) : ?array
{
if (!($this->options['tables']['tablespan'] ?? true)) {
return $block;
}
$headerElements = &$block['element']['elements'][0]['elements'][0]['elements'];
$headerElementsCount = \count($headerElements);
for ($index = $headerElementsCount - 1; $index >= 0; --$index) {
$colspan = 1;
$headerElement = &$headerElements[$index];
while ($index && $headerElements[$index - 1]['handler']['argument'] === '>') {
++$colspan;
$previousHeaderElement = &$headerElements[--$index];
$previousHeaderElement['merged'] = true;
if (isset($previousHeaderElement['attributes'])) {
$headerElement['attributes'] = $previousHeaderElement['attributes'];
}
}
if ($colspan > 1) {
if (!isset($headerElement['attributes'])) {
$headerElement['attributes'] = [];
}
$headerElement['attributes']['colspan'] = $colspan;
}
}
for ($index = 0; $index < $headerElementsCount; ++$index) {
if (isset($headerElements[$index]['merged'])) {
unset($headerElements[$index]);
}
}
$headerElements = \array_values($headerElements);
$rows = &$block['element']['elements'][1]['elements'];
foreach ($rows as $rowNo => &$row) {
$elements = &$row['elements'];
for ($index = \count($elements) - 1; $index >= 0; --$index) {
$colspan = 1;
$element = &$elements[$index];
while ($index && $elements[$index - 1]['handler']['argument'] === '>') {
++$colspan;
$PreviousElement = &$elements[--$index];
$PreviousElement['merged'] = true;
if (isset($PreviousElement['attributes'])) {
$element['attributes'] = $PreviousElement['attributes'];
}
}
if ($colspan > 1) {
if (!isset($element['attributes'])) {
$element['attributes'] = [];
}
$element['attributes']['colspan'] = $colspan;
}
}
}
$rowCount = \count($rows);
foreach ($rows as $rowNo => &$row) {
$elements = &$row['elements'];
foreach ($elements as $index => &$element) {
$rowspan = 1;
if (isset($element['merged'])) {
continue;
}
while ($rowNo + $rowspan < $rowCount
&& $index < \count($rows[$rowNo + $rowspan]['elements'])
&& $rows[$rowNo + $rowspan]['elements'][$index]['handler']['argument'] === '^'
&& ($element['attributes']['colspan'] ?? null) === ($rows[$rowNo + $rowspan]['elements'][$index]['attributes']['colspan'] ?? null)
) {
$rows[$rowNo + $rowspan]['elements'][$index]['merged'] = true;
++$rowspan;
}
if ($rowspan > 1) {
if (!isset($element['attributes'])) {
$element['attributes'] = [];
}
$element['attributes']['rowspan'] = $rowspan;
}
}
}
foreach ($rows as $rowNo => &$row) {
$elements = &$row['elements'];
for ($index = \count($elements) - 1; $index >= 0; --$index) {
if (isset($elements[$index]['merged'])) {
unset($elements[$index]);
}
}
$row['elements'] = \array_values($elements);
}
return $block;
}
/**
* Handle block checkbox
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param null|array $_ Current block (unused parameter)
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockCheckbox(array $line, ?array $_ = null) : ?array
{
$text = \trim($line['text']);
$beginLine = \substr($text, 0, 4);
if ($beginLine === '[ ] ') {
return [
'handler' => 'unchecked',
'text' => \substr(\trim($text), 4),
];
} elseif ($beginLine === '[x] ') {
return [
'handler' => 'checked',
'text' => \substr(\trim($text), 4),
];
}
return null;
}
/**
* Continue checkbox.
*
* This function doesn't do anything!
* However required as per the parsing workflow since it is automatically called.
*
* @param array{body:string, indent:int, text:string} $_ Line data
* @param array $__ Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockCheckboxContinue(array $_, array $__) : ?array
{
return null;
}
/**
* Complete block checkbox
*
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function blockCheckboxComplete(array $block) : array
{
$text = $block['text'];
if ($this->markupEscaped || $this->safeMode) {
$text = \htmlspecialchars($text, \ENT_QUOTES, 'UTF-8');
}
$html = $block['handler'] === 'unchecked'
? '<input type="checkbox" disabled /> ' . $this->formatOnce($text)
: '<input type="checkbox" checked disabled /> ' . $this->formatOnce($text);
$block['element'] = [
'rawHtml' => $html,
'allowRawHtmlInSafeMode' => true,
];
return $block;
}
/**
* Formats text without double escaping
*
* @param string $text Text to format
*
* @return string
*
* @since 1.0.0
*/
protected function formatOnce(string $text) : string
{
// backup settings
$markupEscaped = $this->markupEscaped;
$safeMode = $this->safeMode;
// disable rules to prevent double escaping.
$this->markupEscaped = false;
$this->safeMode = false;
// format line
$text = $this->elements($this->lineElements($text));
// reset old values
$this->markupEscaped = $markupEscaped;
$this->safeMode = $safeMode;
return $text;
}
/**
* Parse attribute data
*
* @param string $attribute Attribute string
*
* @return array
*
* @since 1.0.0
*/
protected function parseAttributeData(string $attribute) : array
{
if (!($this->options['special_attributes'] ?? true)) {
return [];
}
$data = [];
$attributes = \preg_split('/[ ]+/', $attribute, - 1, \PREG_SPLIT_NO_EMPTY);
$classes = [];
if ($attributes === false) {
return [];
}
foreach ($attributes as $attribute) {
if ($attribute[0] === '#') {
$data['id'] = \substr($attribute, 1);
} else { // "."
$classes[] = \substr($attribute, 1);
}
}
if (!empty($classes)) {
$data['class'] = \implode(' ', $classes);
}
return $data;
}
/**
* Encodes the ToC tag to a hashed tag and replace.
*
* This is used to avoid parsing user defined ToC tag which includes "_" in
* their tag such as "[[_toc_]]".
*
* @param string $text Tag text to encode
*
* @return string
*
* @since 1.0.0
*/
protected function encodeToCTagToHash(string $text) : string
{
$salt = \bin2hex(\random_bytes(4));
$tagOrigin = $this->options['toc']['set_toc_tag'] ?? '[toc]';
if (\strpos($text, $tagOrigin) === false) {
return $text;
}
$tagHashed = \hash('sha256', $salt . $tagOrigin);
return \str_replace($tagOrigin, $tagHashed, $text);
}
/**
* Decodes the hashed ToC tag to an original tag and replaces.
*
* This is used to avoid parsing user defined ToC tag which includes "_" in
* their tag such as "[[_toc_]]".
*
* @param string $text Tag text to encode
*
* @return string
*
* @since 1.0.0
*/
protected function decodeToCTagFromHash(string $text) : string
{
$salt = \bin2hex(\random_bytes(4));
$tagOrigin = $this->options['toc']['set_toc_tag'] ?? '[toc]';
$tagHashed = \hash('sha256', $salt . $tagOrigin);
if (\strpos($text, $tagHashed) === false) {
return $text;
}
return \str_replace($tagHashed, $tagOrigin, $text);
}
/**
* Generates an anchor text that are link-able even if the heading is not in ASCII.
*
* @param string $str Header text
*
* @return string
*
* @since 1.0.0
*/
protected function createAnchorID(string $str) : string
{
if ($this->options['toc']['urlencode'] ?? false) {
$str = $this->incrementAnchorId($str);
return \urlencode($str);
}
$charMap = [
// Latin
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'AA', 'Æ' => 'AE', 'Ç' => 'C',
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
'Ð' => 'D', 'Ñ' => 'N', 'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O', 'Ő' => 'O',
'Ø' => 'OE', 'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U', 'Ű' => 'U', 'Ý' => 'Y', 'Þ' => 'TH',
'ß' => 'ss',
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'aa', 'æ' => 'ae', 'ç' => 'c',
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
'ð' => 'd', 'ñ' => 'n', 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ő' => 'o',
'ø' => 'oe', 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ű' => 'u', 'ý' => 'y', 'þ' => 'th',
'ÿ' => 'y',
// Latin symbols
'©' => '(c)', '®' => '(r)', '™' => '(tm)',
// Greek
'Α' => 'A', 'Β' => 'B', 'Γ' => 'G', 'Δ' => 'D', 'Ε' => 'E', 'Ζ' => 'Z', 'Η' => 'H', 'Θ' => '8',
'Ι' => 'I', 'Κ' => 'K', 'Λ' => 'L', 'Μ' => 'M', 'Ν' => 'N', 'Ξ' => '3', 'Ο' => 'O', 'Π' => 'P',
'Ρ' => 'R', 'Σ' => 'S', 'Τ' => 'T', 'Υ' => 'Y', 'Φ' => 'F', 'Χ' => 'X', 'Ψ' => 'PS', 'Ω' => 'W',
'Ά' => 'A', 'Έ' => 'E', 'Ί' => 'I', 'Ό' => 'O', 'Ύ' => 'Y', 'Ή' => 'H', 'Ώ' => 'W', 'Ϊ' => 'I',
'Ϋ' => 'Y',
'α' => 'a', 'β' => 'b', 'γ' => 'g', 'δ' => 'd', 'ε' => 'e', 'ζ' => 'z', 'η' => 'h', 'θ' => '8',
'ι' => 'i', 'κ' => 'k', 'λ' => 'l', 'μ' => 'm', 'ν' => 'n', 'ξ' => '3', 'ο' => 'o', 'π' => 'p',
'ρ' => 'r', 'σ' => 's', 'τ' => 't', 'υ' => 'y', 'φ' => 'f', 'χ' => 'x', 'ψ' => 'ps', 'ω' => 'w',
'ά' => 'a', 'έ' => 'e', 'ί' => 'i', 'ό' => 'o', 'ύ' => 'y', 'ή' => 'h', 'ώ' => 'w', 'ς' => 's',
'ϊ' => 'i', 'ΰ' => 'y', 'ϋ' => 'y', 'ΐ' => 'i',
// Turkish
'Ş' => 'S', 'İ' => 'I', 'Ğ' => 'G',
'ş' => 's', 'ı' => 'i', 'ğ' => 'g',
// Russian
'А' => 'A', 'Б' => 'B', 'В' => 'V', 'Г' => 'G', 'Д' => 'D', 'Е' => 'E', 'Ё' => 'Yo', 'Ж' => 'Zh',
'З' => 'Z', 'И' => 'I', 'Й' => 'J', 'К' => 'K', 'Л' => 'L', 'М' => 'M', 'Н' => 'N', 'О' => 'O',
'П' => 'P', 'Р' => 'R', 'С' => 'S', 'Т' => 'T', 'У' => 'U', 'Ф' => 'F', 'Х' => 'H', 'Ц' => 'C',
'Ч' => 'Ch', 'Ш' => 'Sh', 'Щ' => 'Sh', 'Ъ' => '', 'Ы' => 'Y', 'Ь' => '', 'Э' => 'E', 'Ю' => 'Yu',
'Я' => 'Ya',
'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'д' => 'd', 'е' => 'e', 'ё' => 'yo', 'ж' => 'zh',
'з' => 'z', 'и' => 'i', 'й' => 'j', 'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', 'о' => 'o',
'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', 'у' => 'u', 'ф' => 'f', 'х' => 'h', 'ц' => 'c',
'ч' => 'ch', 'ш' => 'sh', 'щ' => 'sh', 'ъ' => '', 'ы' => 'y', 'ь' => '', 'э' => 'e', 'ю' => 'yu',
'я' => 'ya',
// Ukrainian
'Є' => 'Ye', 'І' => 'I', 'Ї' => 'Yi', 'Ґ' => 'G',
'є' => 'ye', 'і' => 'i', 'ї' => 'yi', 'ґ' => 'g',
// Czech
'Č' => 'C', 'Ď' => 'D', 'Ě' => 'E', 'Ň' => 'N', 'Ř' => 'R', 'Š' => 'S', 'Ť' => 'T', 'Ů' => 'U',
'Ž' => 'Z',
'č' => 'c', 'ď' => 'd', 'ě' => 'e', 'ň' => 'n', 'ř' => 'r', 'š' => 's', 'ť' => 't', 'ů' => 'u',
'ž' => 'z',
// Polish
'Ą' => 'A', 'Ć' => 'C', 'Ę' => 'e', 'Ł' => 'L', 'Ń' => 'N', 'Ś' => 'S', 'Ź' => 'Z',
'Ż' => 'Z',
'ą' => 'a', 'ć' => 'c', 'ę' => 'e', 'ł' => 'l', 'ń' => 'n', 'ś' => 's', 'ź' => 'z',
'ż' => 'z',
// Latvian
'Ā' => 'A', 'Ē' => 'E', 'Ģ' => 'G', 'Ī' => 'i', 'Ķ' => 'k', 'Ļ' => 'L', 'Ņ' => 'N', 'Ū' => 'u',
'ā' => 'a', 'ē' => 'e', 'ģ' => 'g', 'ī' => 'i', 'ķ' => 'k', 'ļ' => 'l', 'ņ' => 'n', 'ū' => 'u',
];
// Transliterate characters to ASCII
if ($this->options['toc']['transliterate'] ?? false) {
$str = \str_replace(\array_keys($charMap), $charMap, $str);
}
// Replace non-alphanumeric characters with our delimiter
$optionDelimiter = $this->options['toc']['delimiter'] ?? '-';
$str = \preg_replace('/[^\p{L}\p{Nd}]+/u', $optionDelimiter, $str) ?? '';
// Remove duplicate delimiters
$str = \preg_replace('/(' . \preg_quote($optionDelimiter, '/') . '){2,}/', '$1', $str) ?? '';
// Truncate slug to max. characters
$optionLimit = $this->options['toc']['limit'] ?? \mb_strlen($str, 'UTF-8');
$str = \mb_substr($str, 0, $optionLimit, 'UTF-8');
// Remove delimiter from ends
$str = \trim($str, $optionDelimiter);
$urlLowercase = $this->options['toc']['lowercase'] ?? true;
$str = $urlLowercase ? \mb_strtolower($str, 'UTF-8') : $str;
return $this->incrementAnchorId($str);
}
/**
* Set/stores the heading block to ToC list in a string and array format.
*
* @param array $content ToC content
*
* @return void
*
* @since 1.0.0
*/
protected function setContentsList(array $content) : void
{
$this->contentsListArray[] = $content;
$text = \trim(\strip_tags($this->elements($this->lineElements($content['text']))));
$id = $content['id'];
$level = (int) \trim($content['level'], 'h');
if ($this->firstHeadLevel === 0) {
$this->firstHeadLevel = $level;
}
// Stores in markdown list format as below:
// - [Header1](#Header1)
// - [Header2-1](#Header2-1)
// - [Header3](#Header3)
// - [Header2-2](#Header2-2)
// ...
$this->contentsListString .= \str_repeat(
' ',
$this->firstHeadLevel - 1 > $level
? 1
: $level - ($this->firstHeadLevel - 1)
) . ' - [' . $text . '](#' . $id . ")\n";
}
/**
* Collect and count anchors in use to prevent duplicated ids.
*
* Also init optional blacklist of ids.
*
* @param string $str Header anchor
*
* @return string Incremental, numeric suffix
*
* @since 1.0.0
*/
protected function incrementAnchorId(string $str) : string
{
// add blacklist to list of used anchors
if (!$this->isBlacklistInitialized) {
$this->initBlacklist();
}
do {
$this->anchorDuplicates[$str] = isset($this->anchorDuplicates[$str]) ? ++$this->anchorDuplicates[$str] : 0;
$newStr = $str;
if (($count = $this->anchorDuplicates[$str]) === 0) {
return $newStr;
}
$newStr .= '-' . $count;
} while (isset($this->anchorDuplicates[$newStr]));
$this->anchorDuplicates[$newStr] = 0;
return $newStr;
}
/**
* Add blacklisted ids to anchor list.
*
* @return void
*
* @since 1.0.0
*/
protected function initBlacklist() : void
{
if ($this->isBlacklistInitialized) {
return;
}
if (!empty($this->options['headings']['blacklist']) && \is_array($this->options['headings']['blacklist'])) {
foreach ($this->options['headings']['blacklist'] as $v) {
$this->anchorDuplicates[(string) $v] = 0;
}
}
$this->isBlacklistInitialized = true;
}
/**
* Parse inline elements
*
* @param string $text Text to parse
* @param array $nonNestables Inline elements that are not allowed to be nested
*
* @return array
*
* @since 1.0.0
*/
protected function lineElements(string $text, array $nonNestables = []) : array
{
$elements = [];
if (!empty($nonNestables)) {
$nonNestables = \array_combine($nonNestables, $nonNestables);
}
// $exc is based on the first occurrence of a marker
while (($exc = \strpbrk($text, $this->inlineMarkerList)) !== false) {
$marker = $exc[0];
$markerPosition = \strlen($text) - \strlen($exc);
// Get the first char before the marker
$beforeMarkerPosition = $markerPosition - 1;
$charBeforeMarker = $beforeMarkerPosition >= 0 ? $text[$markerPosition - 1] : '';
$excerpt = ['text' => $exc, 'context' => $text, 'before' => $charBeforeMarker];
foreach ($this->inlineTypes[$marker] as $inlineType) {
// check to see if the current inline type is nestable in the current context
if (isset($nonNestables[$inlineType])) {
continue;
}
$inline = $this->{"inline{$inlineType}"}($excerpt);
if ($inline === null) {
continue;
}
// makes sure that the inline belongs to "our" marker
if (isset($inline['position']) && $inline['position'] > $markerPosition) {
continue;
}
// sets a default inline position
if (!isset($inline['position'])) {
$inline['position'] = $markerPosition;
}
// cause the new element to 'inherit' our non nestables
$inline['element']['nonNestables'] = isset($inline['element']['nonNestables'])
? \array_merge($inline['element']['nonNestables'], $nonNestables)
: $nonNestables;
// the text that comes before the inline
$unmarkedText = \substr($text, 0, $inline['position']);
// compile the unmarked text
$inlineText = $this->inlineText($unmarkedText);
$elements[] = $inlineText['element'];
// compile the inline
$elements[] = $this->extractElement($inline);
// remove the examined text
$text = \substr($text, $inline['position'] + $inline['extent']);
continue 2;
}
// the marker does not belong to an inline
$unmarkedText = \substr($text, 0, $markerPosition + 1);
$inlineText = $this->inlineText($unmarkedText);
$elements[] = $inlineText['element'];
$text = \substr($text, $markerPosition + 1);
}
$inlineText = $this->inlineText($text);
$elements[] = $inlineText['element'];
foreach ($elements as &$element) {
if (!isset($element['autobreak'])) {
$element['autobreak'] = false;
}
}
return $elements;
}
/**
* Continue block footnote
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockFootnoteContinue(array $line, array $block) : ?array
{
if ($line['text'][0] === '['
&& \preg_match('/^\[\^(.+?)\]:/', $line['text'])
) {
return null;
}
if (isset($block['interrupted'])) {
if ($line['indent'] >= 4) {
$block['text'] .= "\n\n" . $line['text'];
return $block;
}
} else {
$block['text'] .= "\n" . $line['text'];
return $block;
}
return null;
}
/**
* Complete block footnote
*
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function blockFootnoteComplete(array $block) : array
{
$this->definitionData['Footnote'][$block['label']] = [
'text' => $block['text'],
'count' => null,
'number' => null,
];
return $block;
}
/**
* Continue block footnote
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockDefinitionListContinue(array $line, array $block) : ?array
{
if ($line['text'][0] === ':') {
return $this->addDdElement($line, $block);
}
if (isset($block['interrupted']) && $line['indent'] === 0) {
return null;
}
if (isset($block['interrupted'])) {
$block['dd']['handler']['function'] = 'textElements';
$block['dd']['handler']['argument'] .= "\n\n";
$block['dd']['handler']['destination'] = 'elements';
unset($block['interrupted']);
}
$text = \substr($line['body'], \min($line['indent'], 4));
$block['dd']['handler']['argument'] .= "\n" . $text;
return $block;
}
/**
* Continue block markup
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockMarkupContinue(array $line, array $block) : ?array
{
if (isset($block['closed'])) {
return null;
}
if (\preg_match('/^<' . $block['name'] . '(?:[ ]*' . $this->regexHtmlAttribute . ')*[ ]*>/i', $line['text'])) {
// open
++$block['depth'];
}
if (\preg_match('/(.*?)<\/' . $block['name'] . '>[ ]*$/i', $line['text'], $matches)) {
// close
if ($block['depth'] > 0) {
--$block['depth'];
} else {
$block['closed'] = true;
}
}
if (isset($block['interrupted'])) {
$block['element']['rawHtml'] .= "\n";
unset($block['interrupted']);
}
$block['element']['rawHtml'] .= "\n".$line['body'];
return $block;
}
/**
* Complete block markup
*
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function blockMarkupComplete(array $block) : array
{
if (!isset($block['void'])) {
$block['element']['rawHtml'] = $this->processTag($block['element']['rawHtml']);
}
return $block;
}
/**
* Handle footnote marker
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array{extent:int, element:array}
*
* @since 1.0.0
*/
protected function inlineFootnoteMarker(array $excerpt) : ?array
{
if (\preg_match('/^\[\^(.+?)\]/', $excerpt['text'], $matches) !== 1) {
return null;
}
$name = $matches[1];
if (!isset($this->definitionData['Footnote'][$name])) {
return null;
}
++$this->definitionData['Footnote'][$name]['count'];
if (!isset($this->definitionData['Footnote'][$name]['number'])) {
$this->definitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; // » &
}
return [
'extent' => \strlen($matches[0]),
'element' => [
'name' => 'sup',
'attributes' => ['id' => 'fnref' . $this->definitionData['Footnote'][$name]['count'] . ':' . $name],
'element' => [
'name' => 'a',
'attributes' => ['href' => '#fn:' . $name, 'class' => 'footnote-ref'],
'text' => $this->definitionData['Footnote'][$name]['number'],
],
],
];
}
/**
* Insert/replace text with abbreviation
*
* @param array $element Element to insert abbreviation into
*
* @return array
*
* @since 1.0.0
*/
protected function insertAbreviation(array $element) : array
{
if (!isset($element['text'])) {
return $element;
}
$element['elements'] = self::pregReplaceElements(
'/\b' . \preg_quote($this->currentAbbreviation, '/') . '\b/',
[
[
'name' => 'abbr',
'attributes' => [
'title' => $this->currentMeaning,
],
'text' => $this->currentAbbreviation,
],
],
$element['text']
);
unset($element['text']);
return $element;
}
/**
* Inline elements in text
*
* @param string $text Text to search for inlinable elements
*
* @return array
*
* @since 1.0.0
*/
protected function inlineText(string $text) : array
{
$inline = [
'extent' => \strlen($text),
'element' => [],
];
$inline['element']['elements'] = self::pregReplaceElements(
$this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/',
[
['name' => 'br'],
['text' => "\n"],
],
$text
);
// Handle abbreviations
if (!isset($this->definitionData['Abbreviation'])) {
return $inline;
}
foreach ($this->definitionData['Abbreviation'] as $abbreviation => $meaning) {
$this->currentAbbreviation = $abbreviation;
$this->currentMeaning = $meaning;
$inline['element'] = $this->elementApplyRecursiveDepthFirst(
'insertAbreviation',
$inline['element']
);
}
return $inline;
}
/**
* Handle block list
*
* @param array $line Line data
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function addDdElement(array $line, array $block) : array
{
$text = \substr($line['text'], 1);
$text = \trim($text);
unset($block['dd']);
$block['dd'] = [
'name' => 'dd',
'handler' => [
'function' => 'lineElements',
'argument' => $text,
'destination' => 'elements',
],
];
if (isset($block['interrupted'])) {
$block['dd']['handler']['function'] = 'textElements';
unset($block['interrupted']);
}
$block['element']['elements'][] = &$block['dd'];
return $block;
}
/**
* Create footnotes
*
* @return array
*
* @since 1.0.0
*/
protected function buildFootnoteElement() : array
{
$element = [
'name' => 'div',
'attributes' => ['class' => 'footnotes'],
'elements' => [
['name' => 'hr'],
[
'name' => 'ol',
'elements' => [],
],
],
];
\uasort($this->definitionData['Footnote'], [self::class, 'sortFootnotes']);
foreach ($this->definitionData['Footnote'] as $definitionId => $definitionData) {
if (!isset($definitionData['number'])) {
continue;
}
$text = $definitionData['text'];
$textElements = $this->textElements($text);
$numbers = \range(1, $definitionData['count']);
$backLinkElements = [];
foreach ($numbers as $number) {
$backLinkElements[] = ['text' => ' '];
$backLinkElements[] = [
'name' => 'a',
'attributes' => [
'href' => "#fnref{$number}:{$definitionId}",
'rev' => 'footnote',
'class' => 'footnote-backref',
],
'rawHtml' => '&#8617;',
'allowRawHtmlInSafeMode' => true,
'autobreak' => false,
];
}
unset($backLinkElements[0]);
$n = \count($textElements) - 1;
if ($textElements[$n]['name'] === 'p') {
$backLinkElements = \array_merge(
[
[
'rawHtml' => '&#160;',
'allowRawHtmlInSafeMode' => true,
],
],
$backLinkElements
);
unset($textElements[$n]['name']);
$textElements[$n] = [
'name' => 'p',
'elements' => \array_merge(
[$textElements[$n]],
$backLinkElements
),
];
} else {
$textElements[] = [
'name' => 'p',
'elements' => $backLinkElements,
];
}
$element['elements'][1]['elements'][] = [
'name' => 'li',
'attributes' => ['id' => 'fn:' . $definitionId],
'elements' => \array_merge(
$textElements
),
];
}
return $element;
}
/**
* Handle markup/html.
*
* Ensures that html is well formed.
*
* This function is called recursively
*
* @param string $elementMarkup Markup
*
* @return string
*
* @since 1.0.0
*/
protected function processTag(string $elementMarkup) : string
{
// http://stackoverflow.com/q/1148928/200145
\libxml_use_internal_errors(true);
$dom = new \DOMDocument();
// http://stackoverflow.com/q/11309194/200145
$elementMarkup = \mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8');
// http://stackoverflow.com/q/4879946/200145
$dom->loadHTML($elementMarkup);
if ($dom->documentElement === null) {
return '';
}
if ($dom->doctype !== null) {
$dom->removeChild($dom->doctype);
}
if ($dom->firstChild !== null && $dom->firstChild->firstChild?->firstChild !== null) {
$dom->replaceChild($dom->firstChild->firstChild->firstChild, $dom->firstChild);
}
$elementText = '';
if ($dom->documentElement->getAttribute('markdown') === '1') {
foreach ($dom->documentElement->childNodes as $node) {
$elementText .= $dom->saveHTML($node);
}
$dom->documentElement->removeAttribute('markdown');
$elementText = "\n" . $this->text($elementText) . "\n";
} else {
foreach ($dom->documentElement->childNodes as $node) {
$nodeMarkup = $dom->saveHTML($node);
if ($nodeMarkup === false) {
$nodeMarkup = '';
}
$elementText .= $node instanceof \DOMElement && !\in_array($node->nodeName, $this->textLevelElements)
? $this->processTag($nodeMarkup)
: $nodeMarkup;
}
}
// because we don't want for markup to get encoded
$dom->documentElement->nodeValue = 'placeholder\x1A';
$markup = $dom->saveHTML($dom->documentElement);
if ($markup === false) {
return '';
}
return \str_replace('placeholder\x1A', $elementText, $markup);
}
/**
* Footnote sort function
*
* @param array $a First element
* @param array $b Second element
*
* @return int
*
* @since 1.0.0
*/
protected function sortFootnotes(array $a, array $b) : int
{
return $a['number'] <=> $b['number'];
}
/**
* Parse text elements to lines and then handle lines.
*
* @param string $text Text to parse
*
* @return array
*
* @since 1.0.0
*/
protected function textElements(string $text) : array
{
// make sure no definitions are set
$this->definitionData = [];
// standardize line breaks
$text = \str_replace(["\r\n", "\r"], "\n", $text);
// remove surrounding line breaks
$text = \trim($text, "\n");
// split text into lines
$lines = \explode("\n", $text);
// iterate through lines to identify blocks
return $this->linesElements($lines);
}
/**
* Handle lines of elements
*
* @param string[] $lines Lines to parse
*
* @return array
*
* @since 1.0.0
*/
protected function linesElements(array $lines) : array
{
$elements = [];
$currentBlock = null;
foreach ($lines as $line) {
if (\rtrim($line) === '') {
if (isset($currentBlock)) {
$currentBlock['interrupted'] = (isset($currentBlock['interrupted'])
? $currentBlock['interrupted'] + 1 : 1
);
}
continue;
}
while (($beforeTab = \strstr($line, "\t", true)) !== false) {
$shortage = 4 - \mb_strlen($beforeTab, 'utf-8') % 4;
$line = $beforeTab
. \str_repeat(' ', $shortage)
. \substr($line, \strlen($beforeTab) + 1);
}
$indent = \strspn($line, ' ');
$text = $indent > 0 ? \substr($line, $indent) : $line;
$line = ['body' => $line, 'indent' => $indent, 'text' => $text];
if (isset($currentBlock['continuable'])) {
$methodName = 'block' . $currentBlock['type'] . 'Continue';
$block = $this->{$methodName}($line, $currentBlock);
if (isset($block)) {
$currentBlock = $block;
continue;
} elseif (\in_array($currentBlock['type'], self::COMPLETABLE)) {
$methodName = 'block' . $currentBlock['type'] . 'Complete';
$currentBlock = $this->{$methodName}($currentBlock);
}
}
$marker = $text[0];
$blockTypes = $this->unmarkedBlockTypes;
if (isset($this->blockTypes[$marker])) {
foreach ($this->blockTypes[$marker] as $blockType) {
$blockTypes [] = $blockType;
}
}
foreach ($blockTypes as $blockType) {
$block = $this->{"block{$blockType}"}($line, $currentBlock);
if (isset($block)) {
$block['type'] = $blockType;
if (!isset($block['identified'])) {
if (isset($currentBlock)) {
$elements[] = $this->extractElement($currentBlock);
}
$block['identified'] = true;
}
if (\in_array($blockType, self::CONTINUABLE)) {
$block['continuable'] = true;
}
$currentBlock = $block;
continue 2;
}
}
if (isset($currentBlock) && $currentBlock['type'] === 'Paragraph') {
$block = $this->paragraphContinue($line, $currentBlock);
}
if (isset($block)) {
$currentBlock = $block;
} else {
if (isset($currentBlock)) {
$elements[] = $this->extractElement($currentBlock);
}
$currentBlock = [
'type' => 'Paragraph',
'element' => [
'name' => 'p',
'handler' => [
'function' => 'lineElements',
'argument' => $line['text'],
'destination' => 'elements',
],
],
];
$currentBlock['identified'] = true;
}
}
if (isset($currentBlock['continuable']) && \in_array($currentBlock['type'], self::COMPLETABLE)) {
$methodName = 'block' . $currentBlock['type'] . 'Complete';
$currentBlock = $this->{$methodName}($currentBlock);
}
if (isset($currentBlock)) {
$elements[] = $this->extractElement($currentBlock);
}
return $elements;
}
/**
* Extract element from block
*
* @param array $block Block
*
* @return array
*
* @since 1.0.0
*/
protected function extractElement(array $block) : array
{
if (!isset($block['element'])) {
if (isset($block['markup'])) {
$block['element'] = ['rawHtml' => $block['markup']];
} elseif (isset($block['hidden'])) {
$block['element'] = [];
}
}
return $block['element'];
}
/**
* Continue block code
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockCodeContinue(array $line, array $block) : ?array
{
if ($line['indent'] < 4) {
return null;
}
if (isset($block['interrupted'])) {
$block['element']['element']['text'] .= \str_repeat("\n", $block['interrupted']);
unset($block['interrupted']);
}
$block['element']['element']['text'] .= "\n";
$text = \substr($line['body'], 4);
$block['element']['element']['text'] .= $text;
return $block;
}
/**
* Complete block code
*
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function blockCodeComplete(array $block) : array
{
return $block;
}
/**
* Continue block code
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockCommentContinue(array $line, array $block) : ?array
{
if (isset($block['closed'])) {
return null;
}
$block['element']['rawHtml'] .= "\n" . $line['body'];
if (\strpos($line['text'], '-->') !== false) {
$block['closed'] = true;
}
return $block;
}
/**
* Continue block fenced code
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockFencedCodeContinue(array $line, array $block) : ?array
{
if (isset($block['complete'])) {
return null;
}
if (isset($block['interrupted'])) {
$block['element']['element']['text'] .= \str_repeat("\n", $block['interrupted']);
unset($block['interrupted']);
}
if (($len = \strspn($line['text'], $block['char'])) >= $block['openerLength']
&& \rtrim(\substr($line['text'], $len), ' ') === ''
) {
$block['element']['element']['text'] = \substr($block['element']['element']['text'], 1);
$block['complete'] = true;
return $block;
}
$block['element']['element']['text'] .= "\n" . $line['body'];
return $block;
}
/**
* Complete block fenced code
*
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function blockFencedCodeComplete(array $block) : array
{
return $block;
}
/**
* Continue block spoiler
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockSpoilerContinue(array $line, array $block) : ?array
{
if (isset($block['complete'])) {
return null;
}
if (isset($block['interrupted'])) {
$block['element']['element']['text'] .= \str_repeat("\n", $block['interrupted']);
unset($block['interrupted']);
}
if (($len = \strspn($line['text'], $block['char'])) >= $block['openerLength']
&& \rtrim(\substr($line['text'], $len), ' ') === ''
) {
$block['element']['element']['text'] = \substr($block['element']['element']['text'], 1);
$block['complete'] = true;
return $block;
}
$block['element']['element']['elements'][1]['text'] .= "\n" . $line['body'];
return $block;
}
/**
* Complete block spoiler
*
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function blockSpoilerComplete(array $block) : array
{
return $block;
}
/**
* Continue block list
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockListContinue(array $line, array $block) : ?array
{
if (isset($block['interrupted']) && empty($block['li']['handler']['argument'])) {
return null;
}
$requiredIndent = $block['indent'] + \strlen($block['data']['marker']);
if ($line['indent'] < $requiredIndent
&& (($block['data']['type'] === 'ol'
&& \preg_match('/^[0-9]++' . $block['data']['markerTypeRegex'] . '(?:[ ]++(.*)|$)/', $line['text'], $matches)
) || ($block['data']['type'] === 'ul'
&& \preg_match('/^' . $block['data']['markerTypeRegex'] . '(?:[ ]++(.*)|$)/', $line['text'], $matches)
))
) {
if (isset($block['interrupted'])) {
$block['li']['handler']['argument'][] = '';
$block['loose'] = true;
unset($block['interrupted']);
}
unset($block['li']);
$text = $matches[1] ?? '';
$block['indent'] = $line['indent'];
$block['li'] = [
'name' => 'li',
'handler' => [
'function' => 'li',
'argument' => [$text],
'destination' => 'elements',
],
];
$block['element']['elements'][] = &$block['li'];
return $block;
} elseif ($line['indent'] < $requiredIndent && $this->blockList($line)) {
return null;
}
if ($line['text'][0] === '[' && $this->blockReference($line)) {
return $block;
}
if ($line['indent'] >= $requiredIndent) {
if (isset($block['interrupted'])) {
$block['li']['handler']['argument'][] = '';
$block['loose'] = true;
unset($block['interrupted']);
}
$text = \substr($line['body'], $requiredIndent);
$block['li']['handler']['argument'][] = $text;
return $block;
}
if (!isset($block['interrupted'])) {
$text = \preg_replace('/^[ ]{0,' . $requiredIndent . '}+/', '', $line['body']);
$block['li']['handler']['argument'][] = $text;
return $block;
}
return null;
}
/**
* Complete block list
*
* @param array $block Current block
*
* @return array
*
* @since 1.0.0
*/
protected function blockListComplete(array $block) : array
{
if (!isset($block['loose'])) {
return $block;
}
foreach ($block['element']['elements'] as &$li) {
if (\end($li['handler']['argument']) !== '') {
$li['handler']['argument'][] = '';
}
}
return $block;
}
/**
* Continue block quote
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockQuoteContinue(array $line, array $block) : ?array
{
if (isset($block['interrupted'])) {
return null;
}
if ($line['text'][0] === '>' && \preg_match('/^>[ ]?+(.*+)/', $line['text'], $matches)) {
$block['element']['handler']['argument'][] = $matches[1];
return $block;
}
if (!isset($block['interrupted'])) {
$block['element']['handler']['argument'][] = $line['text'];
return $block;
}
return null;
}
/**
* Continue block table
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function blockTableContinue(array $line, array $block) : ?array
{
if (isset($block['interrupted'])
|| (\count($block['alignments']) !== 1 && $line['text'][0] !== '|' && \strpos($line['text'], '|') === false)
) {
return null;
}
$elements = [];
$row = $line['text'];
$row = \trim($row);
$row = \trim($row, '|');
\preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches);
$cells = \array_slice($matches[0], 0, \count($block['alignments']));
foreach ($cells as $index => $cell) {
$cell = \trim($cell);
$element = [
'name' => 'td',
'handler' => [
'function' => 'lineElements',
'argument' => $cell,
'destination' => 'elements',
],
];
if (isset($block['alignments'][$index])) {
$element['attributes'] = [
'style' => 'text-align: ' . $block['alignments'][$index] . ';',
];
}
$elements [] = $element;
}
$element = [
'name' => 'tr',
'elements' => $elements,
];
$block['element']['elements'][1]['elements'][] = $element;
return $block;
}
/**
* Continue block paragraph
*
* @param array{body:string, indent:int, text:string} $line Line data
* @param array $block Current block
*
* @return null|array
*
* @since 1.0.0
*/
protected function paragraphContinue(array $line, array $block) : ?array
{
if (isset($block['interrupted'])) {
return null;
}
$block['element']['handler']['argument'] .= "\n".$line['text'];
return $block;
}
/**
* Handle link
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array
*
* @since 1.0.0
*/
protected function inlineLinkParent(array $excerpt) : ?array
{
$element = [
'name' => 'a',
'handler' => [
'function' => 'lineElements',
'argument' => null,
'destination' => 'elements',
],
'nonNestables' => ['Url', 'Link'],
'attributes' => [
'href' => null,
'title' => null,
],
];
$extent = 0;
$remainder = $excerpt['text'];
if (\preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) {
$element['handler']['argument'] = $matches[1];
$extent += \strlen($matches[0]);
$remainder = \substr($remainder, $extent);
} else {
return null;
}
if (\preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) {
$element['attributes']['href'] = UriFactory::build(\str_replace('{$CSRF}', 'ERROR', $matches[1]));
if (isset($matches[2])) {
$element['attributes']['title'] = \substr($matches[2], 1, - 1);
}
$extent += \strlen($matches[0]);
} else {
if (\preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) {
$definition = \strlen($matches[1]) !== 0 ? $matches[1] : $element['handler']['argument'];
$definition = \strtolower($definition);
$extent += \strlen($matches[0]);
} else {
$definition = \strtolower($element['handler']['argument']);
}
if (!isset($this->definitionData['Reference'][$definition])) {
return null;
}
$definition = $this->definitionData['Reference'][$definition];
$element['attributes']['href'] = $definition['url'];
$element['attributes']['title'] = $definition['title'];
}
return [
'extent' => $extent,
'element' => $element,
];
}
/**
* Handle special character
*
* @param array{text:string, context:string, before:string} $excerpt Inline data
*
* @return null|array
*
* @since 1.0.0
*/
protected function inlineSpecialCharacter(array $excerpt) : ?array
{
if (\substr($excerpt['text'], 1, 1) !== ' ' && \strpos($excerpt['text'], ';') !== false
&& \preg_match('/^&(#?+[0-9a-zA-Z]++);/', $excerpt['text'], $matches)
) {
return [
'element' => ['rawHtml' => '&' . $matches[1] . ';'],
'extent' => \strlen($matches[0]),
];
}
return null;
}
/**
* Handle "handler"
*
* @param array $element Element to handle
*
* @return array
*
* @since 1.0.0
*/
protected function handle(array $element) : array
{
if (!isset($element['handler'])) {
return $element;
}
if (!isset($element['nonNestables'])) {
$element['nonNestables'] = [];
}
if (\is_string($element['handler'])) {
$function = $element['handler'];
$argument = $element['text'];
unset($element['text']);
$destination = 'rawHtml';
} else {
$function = $element['handler']['function'];
$argument = $element['handler']['argument'];
$destination = $element['handler']['destination'];
}
$element[$destination] = $this->{$function}($argument, $element['nonNestables']);
if ($destination === 'handler') {
$element = $this->handle($element);
}
unset($element['handler']);
return $element;
}
/**
* Handle element recursively
*
* @param string|\Closure $closure Closure for handling element
* @param array $element Element to handle
*
* @return array
*
* @since 1.0.0
*/
protected function elementApplyRecursive(string|\Closure $closure, array $element) : array
{
$element = \is_string($closure) ? $this->{$closure}($element) : $closure($element);
if (isset($element['elements'])) {
foreach ($element['elements'] as &$e) {
$e = $this->elementApplyRecursive($closure, $e);
}
} elseif (isset($element['element'])) {
$element['element'] = $this->elementApplyRecursive($closure, $element['element']);
}
return $element;
}
/**
* Handle element recursively
*
* @param string|\Closure $closure Closure for handling element
* @param array $element Element to handle
*
* @return array
*
* @since 1.0.0
*/
protected function elementApplyRecursiveDepthFirst(string|\Closure $closure, array $element) : array
{
if (isset($element['elements'])) {
foreach ($element['elements'] as &$e) {
$e = $this->elementApplyRecursiveDepthFirst($closure, $e);
}
} elseif (isset($element['element'])) {
foreach ($element['element'] as &$e) {
$e = $this->elementApplyRecursiveDepthFirst($closure, $e);
}
}
return \is_string($closure) ? $this->{$closure}($element) : $closure($element);
}
/**
* Render element
*
* @param array $element Element to render
*
* @return string
*
* @since 1.0.0
*/
protected function element(array $element) : string
{
if ($this->safeMode) {
$element = $this->sanitizeElement($element);
}
// identity map if element has no handler
$element = $this->handle($element);
$hasName = isset($element['name']);
$markup = '';
if ($hasName) {
$markup .= '<' . $element['name'];
if (isset($element['attributes'])) {
foreach ($element['attributes'] as $name => $value) {
if ($value === null) {
continue;
}
$markup .= ' ' . $name . '="' . \htmlspecialchars((string) $value, \ENT_QUOTES, 'UTF-8') . '"';
}
}
}
$permitRawHtml = false;
$text = null;
if (isset($element['text'])) {
$text = $element['text'];
} elseif (isset($element['rawHtml'])) {
// very strongly consider an alternative if you're writing an extension
$text = $element['rawHtml'];
$permitRawHtml = !$this->safeMode || ($element['allowRawHtmlInSafeMode'] ?? false);
}
$hasContent = isset($text) || isset($element['element']) || isset($element['elements']);
if ($hasContent) {
$markup .= $hasName ? '>' : '';
if (isset($element['elements'])) {
$markup .= $this->elements($element['elements']);
} elseif (isset($element['element'])) {
$markup .= $this->element($element['element']);
} elseif (!$permitRawHtml) {
$markup .= \htmlspecialchars((string) $text, \ENT_NOQUOTES, 'UTF-8');
} else {
$markup .= $text;
}
$markup .= $hasName ? '</' . $element['name'] . '>' : '';
} elseif ($hasName) {
$markup .= ' />';
}
return $markup;
}
/**
* Render elements
*
* @param array $elements Elements to render
*
* @return string
*
* @since 1.0.0
*/
protected function elements(array $elements) : string
{
$markup = '';
$autoBreak = true;
foreach ($elements as $element) {
if (empty($element)) {
continue;
}
$autoBreakNext = (isset($element['autobreak'])
? $element['autobreak'] : isset($element['name'])
);
// (autobreak === false) covers both sides of an element
$autoBreak = $autoBreak ? $autoBreakNext : $autoBreak;
$markup .= ($autoBreak ? "\n" : '') . $this->element($element);
$autoBreak = $autoBreakNext;
}
return $markup . ($autoBreak ? "\n" : '');
}
/**
* Handle list
*
* @param string[] $lines Lines
*
* @return array
*
* @since 1.0.0
*/
protected function li(array $lines) : array
{
$elements = $this->linesElements($lines);
if (!\in_array('', $lines)
&& isset($elements[0], $elements[0]['name'])
&& $elements[0]['name'] === 'p'
) {
unset($elements[0]['name']);
}
return $elements;
}
/**
* Replace occurrences $regexp with $elements in $text.
*
* @param string $regexp Regex
* @param array $elements Elements to replace
* @param string $text Text to match against regex
*
* @return array
*
* @since 1.0.0
*/
protected static function pregReplaceElements(string $regexp, array $elements, string $text) : array
{
$newElements = [];
while (\preg_match($regexp, $text, $matches, \PREG_OFFSET_CAPTURE)) {
$offset = (int) $matches[0][1];
$before = \substr($text, 0, $offset);
$after = \substr($text, $offset + \strlen($matches[0][0]));
$newElements[] = ['text' => $before];
foreach ($elements as $element) {
$newElements[] = $element;
}
$text = $after;
}
$newElements[] = ['text' => $text];
return $newElements;
}
/**
* Sanitize element
*
* @param array $element Element to sanitize
*
* @return array
*
* @since 1.0.0
*/
protected function sanitizeElement(array $element) : array
{
static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
static $safeUrlNameToAtt = [
'a' => 'href',
'img' => 'src',
];
if (!isset($element['name'])) {
unset($element['attributes']);
return $element;
}
if (isset($safeUrlNameToAtt[$element['name']])) {
$element = $this->filterUnsafeUrlInAttribute($element, $safeUrlNameToAtt[$element['name']]);
}
if (!empty($element['attributes'])) {
foreach ($element['attributes'] as $att => $_) {
if (!\preg_match($goodAttribute, $att)) {
// filter out badly parsed attribute
unset($element['attributes'][$att]);
} elseif (\str_starts_with($att, 'on')) {
// dump onevent attribute
unset($element['attributes'][$att]);
}
}
}
return $element;
}
/**
* Sanitize url in attribute
*
* @param array $element Element to sanitize
* @param string $attribute Attribute to sanitize
*
* @return array
*
* @since 1.0.0
*/
protected function filterUnsafeUrlInAttribute(array $element, string $attribute) : array
{
foreach ($this->safeLinksWhitelist as $scheme) {
if (\str_starts_with($element['attributes'][$attribute], $scheme)) {
return $element;
}
}
$element['attributes'][$attribute] = \str_replace(':', '%3A', $element['attributes'][$attribute]);
return $element;
}
}