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