1: | <?php declare(strict_types=1); |
2: | |
3: | namespace Salient\Console; |
4: | |
5: | use Salient\Console\Support\ConsoleLoopbackFormat as LoopbackFormat; |
6: | use Salient\Console\Support\ConsoleMessageAttributes as MessageAttributes; |
7: | use Salient\Console\Support\ConsoleMessageFormat as MessageFormat; |
8: | use Salient\Console\Support\ConsoleMessageFormats as MessageFormats; |
9: | use Salient\Console\Support\ConsoleTagAttributes as TagAttributes; |
10: | use Salient\Console\Support\ConsoleTagFormats as TagFormats; |
11: | use Salient\Contract\Console\ConsoleFormatInterface as Format; |
12: | use Salient\Contract\Console\ConsoleFormatterInterface as FormatterInterface; |
13: | use Salient\Contract\Console\ConsoleInterface as Console; |
14: | use Salient\Contract\Console\ConsoleTag as Tag; |
15: | use Salient\Core\Concern\ImmutableTrait; |
16: | use Salient\Utility\Regex; |
17: | use Salient\Utility\Str; |
18: | use LogicException; |
19: | use UnexpectedValueException; |
20: | |
21: | |
22: | |
23: | |
24: | final class ConsoleFormatter implements FormatterInterface |
25: | { |
26: | use ImmutableTrait; |
27: | |
28: | public const DEFAULT_LEVEL_PREFIX_MAP = [ |
29: | Console::LEVEL_EMERGENCY => '! ', |
30: | Console::LEVEL_ALERT => '! ', |
31: | Console::LEVEL_CRITICAL => '! ', |
32: | Console::LEVEL_ERROR => '! ', |
33: | Console::LEVEL_WARNING => '^ ', |
34: | Console::LEVEL_NOTICE => '➤ ', |
35: | Console::LEVEL_INFO => '- ', |
36: | Console::LEVEL_DEBUG => ': ', |
37: | ]; |
38: | |
39: | public const DEFAULT_TYPE_PREFIX_MAP = [ |
40: | Console::TYPE_PROGRESS => '⠿ ', |
41: | Console::TYPE_GROUP_START => '» ', |
42: | Console::TYPE_GROUP_END => '« ', |
43: | Console::TYPE_SUMMARY => '» ', |
44: | Console::TYPE_SUCCESS => '✔ ', |
45: | Console::TYPE_FAILURE => '✘ ', |
46: | ]; |
47: | |
48: | |
49: | private const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; |
50: | |
51: | |
52: | |
53: | |
54: | private const TAG_MAP = [ |
55: | '___' => Tag::HEADING, |
56: | '***' => Tag::HEADING, |
57: | '##' => Tag::HEADING, |
58: | '__' => Tag::BOLD, |
59: | '**' => Tag::BOLD, |
60: | '_' => Tag::ITALIC, |
61: | '*' => Tag::ITALIC, |
62: | '<' => Tag::UNDERLINE, |
63: | '~~' => Tag::LOW_PRIORITY, |
64: | ]; |
65: | |
66: | |
67: | |
68: | |
69: | |
70: | private const MARKUP = <<<'REGEX' |
71: | / |
72: | (?(DEFINE) |
73: | (?<endofline> \h*+ \n ) |
74: | (?<endofblock> ^ \k<indent> \k<fence> \h*+ $ ) |
75: | (?<endofspan> \k<backtickstring> (?! ` ) ) |
76: | ) |
77: | # Do not allow gaps between matches |
78: | \G |
79: | # Do not allow empty matches |
80: | (?= . ) |
81: | # Claim indentation early so horizontal whitespace before fenced code |
82: | # blocks is not mistaken for text |
83: | (?<indent> ^ \h*+ )? |
84: | (?: |
85: | # Whitespace before paragraphs |
86: | (?<breaks> (?&endofline)+ ) | |
87: | # Everything except unescaped backticks until the start of the next |
88: | # paragraph |
89: | (?<text> (?> (?: [^\\`\n]+ | \\ [-\\!"\#$%&'()*+,.\/:;<=>?@[\]^_`{|}~\n] | \\ | \n (?! (?&endofline) ) )+ (?&endofline)* ) ) | |
90: | # CommonMark-compliant fenced code blocks |
91: | (?> (?(indent) |
92: | (?> (?<fence> ```+ ) (?<infostring> [^\n]* ) \n ) |
93: | # Match empty blocks--with no trailing newline--and blocks with an |
94: | # empty line by making the subsequent newline conditional on inblock |
95: | (?<block> (?> (?<inblock> (?: (?! (?&endofblock) ) (?: \k<indent> | (?= (?&endofline) ) ) [^\n]* (?: (?= \n (?&endofblock) ) | \n | \z ) )+ )? ) ) |
96: | # Allow code fences to terminate at the end of the subject |
97: | (?: (?(inblock) \n ) (?&endofblock) | \z ) | \z |
98: | ) ) | |
99: | # CommonMark-compliant code spans |
100: | (?<backtickstring> (?> `+ ) ) (?<span> (?> (?: [^`]+ | (?! (?&endofspan) ) `+ )* ) ) (?&endofspan) | |
101: | # Unmatched backticks |
102: | (?<extra> `+ ) | |
103: | \z |
104: | ) /mxs |
105: | REGEX; |
106: | |
107: | |
108: | |
109: | |
110: | |
111: | private const TAG = <<<'REGEX' |
112: | / |
113: | (?(DEFINE) |
114: | (?<esc> \\ [-\\!"\#$%&'()*+,.\/:;<=>?@[\]^_`{|}~] | \\ ) |
115: | ) |
116: | (?<! \\ ) (?: \\\\ )* \K (?| |
117: | \b (?<tag> _ {1,3}+ ) (?! \s ) (?> (?<text> (?: [^_\\]+ | (?&esc) | (?! (?<! \s ) \k<tag> \b ) _ + )* ) ) (?<! \s ) \k<tag> \b | |
118: | (?<tag> \* {1,3}+ ) (?! \s ) (?> (?<text> (?: [^*\\]+ | (?&esc) | (?! (?<! \s ) \k<tag> ) \* + )* ) ) (?<! \s ) \k<tag> | |
119: | (?<tag> < ) (?! \s ) (?> (?<text> (?: [^>\\]+ | (?&esc) | (?! (?<! \s ) > ) > + )* ) ) (?<! \s ) > | |
120: | (?<tag> ~~ ) (?! \s ) (?> (?<text> (?: [^~\\]+ | (?&esc) | (?! (?<! \s ) ~~ ) ~ + )* ) ) (?<! \s ) ~~ | |
121: | ^ (?<tag> \#\# ) \h+ (?> (?<text> (?: [^\#\s\\]+ | (?&esc) | \#+ (?! \h* $ ) | \h++ (?! (?: \#+ \h* )? $ ) )* ) ) (?: \h+ \#+ | \h* ) $ |
122: | ) /mx |
123: | REGEX; |
124: | |
125: | |
126: | |
127: | |
128: | |
129: | private const ESCAPE = <<<'REGEX' |
130: | / |
131: | (?| |
132: | \\ ( [-\\ !"\#$%&'()*+,.\/:;<=>?@[\]^_`{|}~] ) | |
133: | # Lookbehind assertions are unnecessary because the first branch |
134: | # matches escaped spaces and backslashes |
135: | \ ? \\ ( \n ) |
136: | ) /x |
137: | REGEX; |
138: | |
139: | private static ConsoleFormatter $DefaultFormatter; |
140: | private static TagFormats $DefaultTagFormats; |
141: | private static MessageFormats $DefaultMessageFormats; |
142: | private static TagFormats $LoopbackTagFormats; |
143: | private TagFormats $TagFormats; |
144: | private MessageFormats $MessageFormats; |
145: | |
146: | private $WidthCallback; |
147: | |
148: | private array $LevelPrefixMap; |
149: | |
150: | private array $TypePrefixMap; |
151: | |
152: | private array $SpinnerState; |
153: | |
154: | |
155: | |
156: | |
157: | |
158: | |
159: | public function __construct( |
160: | ?TagFormats $tagFormats = null, |
161: | ?MessageFormats $messageFormats = null, |
162: | ?callable $widthCallback = null, |
163: | array $levelPrefixMap = ConsoleFormatter::DEFAULT_LEVEL_PREFIX_MAP, |
164: | array $typePrefixMap = ConsoleFormatter::DEFAULT_TYPE_PREFIX_MAP |
165: | ) { |
166: | $this->TagFormats = $tagFormats ?: $this->getDefaultTagFormats(); |
167: | $this->MessageFormats = $messageFormats ?: $this->getDefaultMessageFormats(); |
168: | $this->WidthCallback = $widthCallback ?: fn(): ?int => null; |
169: | $this->LevelPrefixMap = $levelPrefixMap; |
170: | $this->TypePrefixMap = $typePrefixMap; |
171: | } |
172: | |
173: | |
174: | |
175: | |
176: | public function withSpinnerState(?array &$state) |
177: | { |
178: | if ($state === null) { |
179: | $state = [0, 0.0]; |
180: | } |
181: | $clone = clone $this; |
182: | $clone->SpinnerState = &$state; |
183: | return $clone; |
184: | } |
185: | |
186: | |
187: | |
188: | |
189: | public function withUnescape(bool $value = true) |
190: | { |
191: | return $this->with('TagFormats', $this->TagFormats->withUnescape($value)); |
192: | } |
193: | |
194: | |
195: | |
196: | |
197: | public function withWrapAfterApply(bool $value = true) |
198: | { |
199: | return $this->with('TagFormats', $this->TagFormats->withWrapAfterApply($value)); |
200: | } |
201: | |
202: | |
203: | |
204: | |
205: | public function getTagFormat(int $tag): Format |
206: | { |
207: | return $this->TagFormats->getFormat($tag); |
208: | } |
209: | |
210: | |
211: | |
212: | |
213: | public function getMessageFormat( |
214: | int $level, |
215: | int $type = Console::TYPE_STANDARD |
216: | ): MessageFormat { |
217: | return $this->MessageFormats->get($level, $type); |
218: | } |
219: | |
220: | |
221: | |
222: | |
223: | public function getUnescape(): bool |
224: | { |
225: | return $this->TagFormats->getUnescape(); |
226: | } |
227: | |
228: | |
229: | |
230: | |
231: | public function getWrapAfterApply(): bool |
232: | { |
233: | return $this->TagFormats->getWrapAfterApply(); |
234: | } |
235: | |
236: | |
237: | |
238: | |
239: | public function getMessagePrefix( |
240: | int $level, |
241: | int $type = Console::TYPE_STANDARD |
242: | ): string { |
243: | if ($type === Console::TYPE_UNFORMATTED || $type === Console::TYPE_UNDECORATED) { |
244: | return ''; |
245: | } |
246: | if ($type === Console::TYPE_PROGRESS && isset($this->SpinnerState)) { |
247: | $frames = count(self::SPINNER); |
248: | $prefix = self::SPINNER[$this->SpinnerState[0] % $frames] . ' '; |
249: | $now = (float) (hrtime(true) / 1000); |
250: | if ($now - $this->SpinnerState[1] >= 80000) { |
251: | $this->SpinnerState[0]++; |
252: | $this->SpinnerState[0] %= $frames; |
253: | $this->SpinnerState[1] = $now; |
254: | } |
255: | } |
256: | return $prefix |
257: | ?? $this->TypePrefixMap[$type] |
258: | ?? $this->LevelPrefixMap[$level] |
259: | ?? ''; |
260: | } |
261: | |
262: | |
263: | |
264: | |
265: | public function format( |
266: | string $string, |
267: | bool $unwrap = false, |
268: | $wrapToWidth = null, |
269: | bool $unformat = false, |
270: | string $break = "\n" |
271: | ): string { |
272: | if ($string === '' || $string === "\r") { |
273: | return $string; |
274: | } |
275: | |
276: | |
277: | |
278: | $replace = []; |
279: | $append = ''; |
280: | $unescape = $this->getUnescape(); |
281: | $wrapAfterApply = $this->getWrapAfterApply(); |
282: | $textFormats = $wrapAfterApply |
283: | ? $this->TagFormats |
284: | : $this->getDefaultTagFormats(); |
285: | $formattedFormats = $unformat |
286: | ? $this->getLoopbackTagFormats() |
287: | : $this->TagFormats; |
288: | |
289: | |
290: | if ($string[-1] === "\r") { |
291: | $append .= "\r"; |
292: | $string = substr($string, 0, -1); |
293: | } |
294: | |
295: | |
296: | |
297: | if (!Regex::matchAll( |
298: | self::MARKUP, |
299: | Str::setEol($string), |
300: | $matches, |
301: | \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL |
302: | )) { |
303: | throw new UnexpectedValueException( |
304: | sprintf('Unable to parse: %s', $string) |
305: | ); |
306: | } |
307: | |
308: | $string = ''; |
309: | foreach ($matches as $match) { |
310: | $indent = (string) $match['indent']; |
311: | |
312: | if ($match['breaks'] !== null) { |
313: | $breaks = $match['breaks']; |
314: | if ($unwrap && strpos($breaks, "\n") !== false) { |
315: | |
316: | $breaks = substr(Str::unwrap(".$breaks.", "\n", false, true, true), 1, -1); |
317: | } |
318: | $string .= $indent . $breaks; |
319: | continue; |
320: | } |
321: | |
322: | |
323: | if ($match['extra'] !== null) { |
324: | $string .= $indent . $match['extra']; |
325: | continue; |
326: | } |
327: | |
328: | $baseOffset = strlen($string . $indent); |
329: | |
330: | if ($match['text'] !== null) { |
331: | $text = $match['text']; |
332: | if ($unwrap && strpos($text, "\n") !== false) { |
333: | |
334: | $text = substr(Str::unwrap(".$text.", "\n", false, true, true), 1, -1); |
335: | } |
336: | |
337: | $adjust = 0; |
338: | $text = Regex::replaceCallback( |
339: | self::TAG, |
340: | function ($matches) use ( |
341: | &$replace, |
342: | $textFormats, |
343: | $formattedFormats, |
344: | $baseOffset, |
345: | &$adjust |
346: | ): string { |
347: | $text = $this->applyTags( |
348: | $matches, |
349: | true, |
350: | $textFormats->getUnescape(), |
351: | $textFormats |
352: | ); |
353: | $placeholder = Regex::replace('/[^ ]/u', 'x', $text); |
354: | $formatted = $textFormats === $formattedFormats |
355: | ? $text |
356: | : $this->applyTags( |
357: | $matches, |
358: | true, |
359: | $formattedFormats->getUnescape(), |
360: | $formattedFormats |
361: | ); |
362: | $replace[] = [ |
363: | $baseOffset + $matches[0][1] + $adjust, |
364: | strlen($placeholder), |
365: | $formatted, |
366: | ]; |
367: | $adjust += strlen($placeholder) - strlen($matches[0][0]); |
368: | return $placeholder; |
369: | }, |
370: | $text, |
371: | -1, |
372: | $count, |
373: | \PREG_OFFSET_CAPTURE |
374: | ); |
375: | |
376: | $string .= $indent . $text; |
377: | continue; |
378: | } |
379: | |
380: | if ($match['block'] !== null) { |
381: | |
382: | if ($unwrap && $string !== '' && $string[-1] !== "\n") { |
383: | $string[-1] = "\n"; |
384: | } |
385: | |
386: | |
387: | $formatted = $formattedFormats->apply( |
388: | $match['block'], |
389: | new TagAttributes( |
390: | Tag::CODE_BLOCK, |
391: | $match['fence'], |
392: | 0, |
393: | false, |
394: | $indent, |
395: | Str::coalesce(trim($match['infostring']), null), |
396: | ) |
397: | ); |
398: | $placeholder = '?'; |
399: | $replace[] = [ |
400: | $baseOffset, |
401: | 1, |
402: | $formatted, |
403: | ]; |
404: | |
405: | $string .= $indent . $placeholder; |
406: | continue; |
407: | } |
408: | |
409: | if ($match['span'] !== null) { |
410: | |
411: | $span = $match['span']; |
412: | |
413: | |
414: | |
415: | |
416: | $span = Regex::replace( |
417: | '/^ ((?> *[^ ]+).*) $/u', |
418: | '$1', |
419: | strtr($span, "\n", ' '), |
420: | ); |
421: | $attributes = new TagAttributes( |
422: | Tag::CODE_SPAN, |
423: | $match['backtickstring'], |
424: | ); |
425: | $text = $textFormats->apply($span, $attributes); |
426: | $placeholder = Regex::replace('/[^ ]/u', 'x', $text); |
427: | $formatted = $textFormats === $formattedFormats |
428: | ? $text |
429: | : $formattedFormats->apply($span, $attributes); |
430: | $replace[] = [ |
431: | $baseOffset, |
432: | strlen($placeholder), |
433: | $formatted, |
434: | ]; |
435: | |
436: | $string .= $indent . $placeholder; |
437: | continue; |
438: | } |
439: | } |
440: | |
441: | |
442: | |
443: | $replacements = count($replace); |
444: | $adjustable = []; |
445: | foreach ($replace as $i => [$offset]) { |
446: | $adjustable[$i] = $offset; |
447: | } |
448: | $adjust = 0; |
449: | $string = Regex::replaceCallback( |
450: | self::ESCAPE, |
451: | function ($matches) use ( |
452: | $unformat, |
453: | $unescape, |
454: | $wrapAfterApply, |
455: | &$replace, |
456: | &$adjustable, |
457: | &$adjust |
458: | ): string { |
459: | |
460: | |
461: | if ($wrapAfterApply && !$unescape) { |
462: | if ($matches[1][0] !== ' ') { |
463: | return $matches[0][0]; |
464: | } |
465: | $placeholder = '\x'; |
466: | $replace[] = [ |
467: | $matches[0][1] + $adjust, |
468: | strlen($placeholder), |
469: | $matches[0][0], |
470: | ]; |
471: | return $placeholder; |
472: | } |
473: | |
474: | $delta = strlen($matches[1][0]) - strlen($matches[0][0]); |
475: | foreach ($adjustable as $i => $offset) { |
476: | if ($offset < $matches[0][1]) { |
477: | continue; |
478: | } |
479: | $replace[$i][0] += $delta; |
480: | } |
481: | |
482: | $placeholder = null; |
483: | if ($matches[1][0] === ' ') { |
484: | $placeholder = 'x'; |
485: | } |
486: | |
487: | if ($unformat || !$unescape || $placeholder !== null) { |
488: | |
489: | $replace[] = [ |
490: | $matches[0][1] + $adjust, |
491: | strlen($matches[1][0]), |
492: | $unformat || !$unescape ? $matches[0][0] : $matches[1][0], |
493: | ]; |
494: | } |
495: | |
496: | $adjust += $delta; |
497: | |
498: | return $placeholder ?? $matches[1][0]; |
499: | }, |
500: | $string, |
501: | -1, |
502: | $count, |
503: | \PREG_OFFSET_CAPTURE |
504: | ); |
505: | |
506: | if (is_array($wrapToWidth)) { |
507: | for ($i = 0; $i < 2; $i++) { |
508: | if ($wrapToWidth[$i] <= 0) { |
509: | $width ??= ($this->WidthCallback)(); |
510: | if ($width === null) { |
511: | $wrapToWidth = null; |
512: | break; |
513: | } |
514: | $wrapToWidth[$i] = max(0, $wrapToWidth[$i] + $width); |
515: | } |
516: | } |
517: | } elseif ( |
518: | is_int($wrapToWidth) |
519: | && $wrapToWidth <= 0 |
520: | ) { |
521: | $width = ($this->WidthCallback)(); |
522: | $wrapToWidth = |
523: | $width === null |
524: | ? null |
525: | : max(0, $wrapToWidth + $width); |
526: | } |
527: | if ($wrapToWidth !== null) { |
528: | if ($break === "\n") { |
529: | $string = Str::wrap($string, $wrapToWidth); |
530: | } else { |
531: | |
532: | $wrapped = Str::wrap($string, $wrapToWidth); |
533: | $length = strlen($wrapped); |
534: | for ($i = 0; $i < $length; $i++) { |
535: | if ($wrapped[$i] === "\n" && $string[$i] !== "\n") { |
536: | $replace[] = [$i, 1, $break]; |
537: | } |
538: | } |
539: | } |
540: | } |
541: | |
542: | |
543: | |
544: | if (count($replace) !== $replacements) { |
545: | usort($replace, fn(array $a, array $b): int => $b[0] <=> $a[0]); |
546: | } else { |
547: | $replace = array_reverse($replace); |
548: | } |
549: | |
550: | foreach ($replace as [$offset, $length, $replacement]) { |
551: | $string = substr_replace($string, $replacement, $offset, $length); |
552: | } |
553: | |
554: | return $string . $append; |
555: | } |
556: | |
557: | |
558: | |
559: | |
560: | public function formatMessage( |
561: | string $msg1, |
562: | ?string $msg2 = null, |
563: | int $level = Console::LEVEL_INFO, |
564: | int $type = Console::TYPE_STANDARD |
565: | ): string { |
566: | $attributes = new MessageAttributes($level, $type); |
567: | |
568: | if ($type === Console::TYPE_UNFORMATTED) { |
569: | return $this |
570: | ->getDefaultMessageFormats() |
571: | ->get($level, $type) |
572: | ->apply($msg1, $msg2, '', $attributes); |
573: | } |
574: | |
575: | $prefix = $this->getMessagePrefix($level, $type); |
576: | |
577: | return $this |
578: | ->MessageFormats |
579: | ->get($level, $type) |
580: | ->apply($msg1, $msg2, $prefix, $attributes); |
581: | } |
582: | |
583: | |
584: | |
585: | |
586: | public function formatDiff(string $diff): string |
587: | { |
588: | $formats = [ |
589: | '---' => $this->TagFormats->getFormat(Tag::DIFF_HEADER), |
590: | '+++' => $this->TagFormats->getFormat(Tag::DIFF_HEADER), |
591: | '@' => $this->TagFormats->getFormat(Tag::DIFF_RANGE), |
592: | '+' => $this->TagFormats->getFormat(Tag::DIFF_ADDITION), |
593: | '-' => $this->TagFormats->getFormat(Tag::DIFF_REMOVAL), |
594: | ]; |
595: | |
596: | return Regex::replaceCallback( |
597: | '/^(-{3}|\+{3}|[-+@]).*/m', |
598: | fn(array $matches) => $formats[$matches[1]]->apply($matches[0]), |
599: | $diff, |
600: | ); |
601: | } |
602: | |
603: | |
604: | |
605: | |
606: | public static function escapeTags(string $string, bool $newlines = false): string |
607: | { |
608: | |
609: | |
610: | $escaped = addcslashes($string, '\#*<>_`~'); |
611: | return $newlines |
612: | ? str_replace("\n", "\\\n", $escaped) |
613: | : $escaped; |
614: | } |
615: | |
616: | |
617: | |
618: | |
619: | public static function unescapeTags(string $string): string |
620: | { |
621: | return Regex::replace( |
622: | self::ESCAPE, |
623: | '$1', |
624: | $string, |
625: | ); |
626: | } |
627: | |
628: | |
629: | |
630: | |
631: | public static function removeTags(string $string): string |
632: | { |
633: | return self::getDefaultFormatter()->format($string); |
634: | } |
635: | |
636: | private static function getDefaultFormatter(): self |
637: | { |
638: | return self::$DefaultFormatter ??= new self(); |
639: | } |
640: | |
641: | private static function getDefaultTagFormats(): TagFormats |
642: | { |
643: | return self::$DefaultTagFormats ??= new TagFormats(); |
644: | } |
645: | |
646: | private static function getDefaultMessageFormats(): MessageFormats |
647: | { |
648: | return self::$DefaultMessageFormats ??= new MessageFormats(); |
649: | } |
650: | |
651: | private static function getLoopbackTagFormats(): TagFormats |
652: | { |
653: | return self::$LoopbackTagFormats ??= LoopbackFormat::getTagFormats(); |
654: | } |
655: | |
656: | |
657: | |
658: | |
659: | private function applyTags( |
660: | array $matches, |
661: | bool $matchesHasOffset, |
662: | bool $unescape, |
663: | TagFormats $formats, |
664: | int $depth = 0 |
665: | ): string { |
666: | |
667: | $text = $matchesHasOffset ? $matches['text'][0] : $matches['text']; |
668: | |
669: | $tag = $matchesHasOffset ? $matches['tag'][0] : $matches['tag']; |
670: | |
671: | $text = Regex::replaceCallback( |
672: | self::TAG, |
673: | fn(array $matches): string => |
674: | $this->applyTags($matches, false, $unescape, $formats, $depth + 1), |
675: | $text, |
676: | -1, |
677: | $count, |
678: | ); |
679: | |
680: | if ($unescape) { |
681: | $text = Regex::replace( |
682: | self::ESCAPE, |
683: | '$1', |
684: | $text, |
685: | ); |
686: | } |
687: | |
688: | $tagId = self::TAG_MAP[$tag] ?? null; |
689: | if ($tagId === null) { |
690: | throw new LogicException(sprintf('Invalid tag: %s', $tag)); |
691: | } |
692: | |
693: | return $formats->apply( |
694: | $text, |
695: | new TagAttributes($tagId, $tag, $depth, (bool) $count) |
696: | ); |
697: | } |
698: | } |
699: | |