Skip to content

Commit 059edb2

Browse files
emmadesilvagithub-actions[bot]
authored andcommitted
Merge pull request #2576 from hydephp/v3/terminal-block-view-model
[3.x] Render terminal blocks through a view model hydephp/develop@756fc68
1 parent 7aaf732 commit 059edb2

7 files changed

Lines changed: 373 additions & 86 deletions

File tree

src/Markdown/Extensions/Nodes/TerminalBlock.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,14 @@
44

55
namespace Hyde\Markdown\Extensions\Nodes;
66

7+
use Hyde\Markdown\Extensions\TerminalBlockViewModel;
78
use League\CommonMark\Node\Block\AbstractBlock;
89

910
/** @internal */
1011
class TerminalBlock extends AbstractBlock
1112
{
12-
public function __construct(
13-
public readonly string $literal,
14-
public readonly bool $usesSymfonyFormatting = false,
15-
public readonly ?string $title = null,
16-
) {
13+
public function __construct(public readonly TerminalBlockViewModel $viewModel)
14+
{
1715
parent::__construct();
1816
}
1917
}

src/Markdown/Extensions/Processing/TerminalBlockRenderer.php

Lines changed: 1 addition & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,8 @@
1010
use League\CommonMark\Renderer\ChildNodeRendererInterface;
1111
use League\CommonMark\Renderer\NodeRendererInterface;
1212

13-
use function array_map;
14-
use function array_pop;
15-
use function count;
16-
use function end;
17-
use function e;
18-
use function explode;
1913
use function get_class;
20-
use function implode;
21-
use function preg_match;
22-
use function preg_split;
2314
use function sprintf;
24-
use function view;
2515

2616
/** @internal */
2717
class TerminalBlockRenderer implements NodeRendererInterface
@@ -32,61 +22,6 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer): s
3222
throw new InvalidArgumentException(sprintf('Incompatible node type: %s', get_class($node)));
3323
}
3424

35-
return view('hyde::components.markdown.terminal', [
36-
'contents' => $this->renderContents($node),
37-
'title' => $node->title,
38-
])->render();
39-
}
40-
41-
protected function renderContents(TerminalBlock $node): string
42-
{
43-
return implode("\n", array_map(
44-
fn (string $line): string => $this->renderLine($line, $node->usesSymfonyFormatting),
45-
explode("\n", $node->literal),
46-
));
47-
}
48-
49-
protected function renderLine(string $line, bool $usesSymfonyFormatting): string
50-
{
51-
if (preg_match('/^(\$[\t ]+)(.*)$/', $line, $matches)) {
52-
return sprintf(
53-
'<span class="hyde-terminal-command text-[#C3E88D]"><span class="hyde-terminal-prompt select-none" aria-hidden="true">%s</span>%s</span>',
54-
e($matches[1]),
55-
$this->renderText($matches[2], $usesSymfonyFormatting),
56-
);
57-
}
58-
59-
return $this->renderText($line, $usesSymfonyFormatting);
60-
}
61-
62-
protected function renderText(string $text, bool $usesSymfonyFormatting): string
63-
{
64-
if (! $usesSymfonyFormatting) {
65-
return e($text);
66-
}
67-
68-
$output = '';
69-
$stack = [];
70-
$parts = preg_split('/(<\/?(?:info|comment|question|error)>)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
71-
72-
foreach ($parts ?: [] as $part) {
73-
if (preg_match('/^<(info|comment|question|error)>$/', $part, $matches)) {
74-
$stack[] = $matches[1];
75-
$output .= match ($matches[1]) {
76-
'info' => '<span class="hyde-terminal-info text-[#C3E88D]">',
77-
'comment' => '<span class="hyde-terminal-comment text-[#FFCB6B]">',
78-
'question' => '<span class="hyde-terminal-question text-[#89DDFF]">',
79-
'error' => '<span class="hyde-terminal-error font-semibold text-[#F07178]">',
80-
};
81-
} elseif (preg_match('/^<\/(info|comment|question|error)>$/', $part, $matches)
82-
&& end($stack) === $matches[1]) {
83-
array_pop($stack);
84-
$output .= '</span>';
85-
} else {
86-
$output .= e($part);
87-
}
88-
}
89-
90-
return $output.str_repeat('</span>', count($stack));
25+
return $node->viewModel->render();
9126
}
9227
}

src/Markdown/Extensions/Processing/TransformTerminalBlocks.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Hyde\Markdown\Extensions\Processing;
66

77
use Hyde\Markdown\Extensions\Nodes\TerminalBlock;
8+
use Hyde\Markdown\Extensions\TerminalBlockViewModel;
89
use InvalidArgumentException;
910
use League\CommonMark\Event\DocumentParsedEvent;
1011
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
@@ -39,12 +40,17 @@ public function __invoke(DocumentParsedEvent $event): void
3940
}
4041

4142
foreach ($terminalBlocks as $node) {
42-
[$usesSymfonyFormatting, $title] = $this->parseModifiers($node->getInfo() ?? '');
43-
44-
$node->replaceWith(new TerminalBlock($node->getLiteral(), $usesSymfonyFormatting, $title));
43+
$node->replaceWith(new TerminalBlock($this->makeViewModel($node)));
4544
}
4645
}
4746

47+
protected function makeViewModel(FencedCode $node): TerminalBlockViewModel
48+
{
49+
[$usesSymfonyFormatting, $title] = $this->parseModifiers($node->getInfo() ?? '');
50+
51+
return new TerminalBlockViewModel($node->getLiteral(), $title, $usesSymfonyFormatting);
52+
}
53+
4854
/**
4955
* Parse the modifiers following the language, which are order-independent.
5056
*
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hyde\Markdown\Extensions;
6+
7+
use function array_map;
8+
use function array_pop;
9+
use function count;
10+
use function e;
11+
use function end;
12+
use function explode;
13+
use function implode;
14+
use function preg_match;
15+
use function preg_split;
16+
use function sprintf;
17+
use function str_repeat;
18+
use function view;
19+
20+
/** @internal */
21+
class TerminalBlockViewModel
22+
{
23+
public readonly string $contents;
24+
25+
public function __construct(
26+
public readonly string $literal,
27+
public readonly ?string $title = null,
28+
public readonly bool $usesSymfonyFormatting = false,
29+
) {
30+
$this->contents = $this->formatContents();
31+
}
32+
33+
public function render(): string
34+
{
35+
return view('hyde::components.markdown.terminal', $this->viewData())->render();
36+
}
37+
38+
/** @return array{contents: string, title: ?string} */
39+
protected function viewData(): array
40+
{
41+
return [
42+
'contents' => $this->contents,
43+
'title' => $this->title,
44+
];
45+
}
46+
47+
protected function formatContents(): string
48+
{
49+
return implode("\n", array_map(
50+
fn (string $line): string => $this->formatLine($line),
51+
explode("\n", $this->literal),
52+
));
53+
}
54+
55+
protected function formatLine(string $line): string
56+
{
57+
if (preg_match('/^(\$[\t ]+)(.*)$/', $line, $matches)) {
58+
return sprintf(
59+
'<span class="hyde-terminal-command text-[#C3E88D]"><span class="hyde-terminal-prompt select-none" aria-hidden="true">%s</span>%s</span>',
60+
e($matches[1]),
61+
$this->formatText($matches[2]),
62+
);
63+
}
64+
65+
return $this->formatText($line);
66+
}
67+
68+
protected function formatText(string $text): string
69+
{
70+
if (! $this->usesSymfonyFormatting) {
71+
return e($text);
72+
}
73+
74+
$output = '';
75+
$stack = [];
76+
$parts = preg_split('/(<\/?(?:info|comment|question|error)>)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
77+
78+
foreach ($parts ?: [] as $part) {
79+
if (preg_match('/^<(info|comment|question|error)>$/', $part, $matches)) {
80+
$stack[] = $matches[1];
81+
$output .= match ($matches[1]) {
82+
'info' => '<span class="hyde-terminal-info text-[#C3E88D]">',
83+
'comment' => '<span class="hyde-terminal-comment text-[#FFCB6B]">',
84+
'question' => '<span class="hyde-terminal-question text-[#89DDFF]">',
85+
'error' => '<span class="hyde-terminal-error font-semibold text-[#F07178]">',
86+
};
87+
} elseif (preg_match('/^<\/(info|comment|question|error)>$/', $part, $matches)
88+
&& end($stack) === $matches[1]) {
89+
array_pop($stack);
90+
$output .= '</span>';
91+
} else {
92+
$output .= e($part);
93+
}
94+
}
95+
96+
return $output.str_repeat('</span>', count($stack));
97+
}
98+
}

tests/Feature/Documentation/ComposableMarkdownBlocksDocumentationTest.php

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,14 +1111,14 @@ public function testTheBuiltInBlockViewsOptOutOfTheProseStyles()
11111111
$this->assertStringContainsString('not-prose', $this->frameworkViewContents('filepath-label.blade.php'));
11121112
}
11131113

1114-
public function testTheBuiltInBlocksPassDataRatherThanMarkupToTheirViews()
1114+
public function testTheBuiltInBlocksGiveTheirViewsSemanticValuesRatherThanPrecomputedClasses()
11151115
{
1116-
// The blocks give the view their type, level, or path, rather than a pre-baked class string
1117-
$this->assertSame(['literal', 'usesSymfonyFormatting', 'title'], $this->constructorParameters(TerminalBlock::class));
1118-
1116+
// The blocks give the view their title, type, or level, rather than a pre-baked class string
1117+
$this->publishView('vendor/hyde/components/markdown/terminal.blade.php', '{{ $title }}');
11191118
$this->publishView('vendor/hyde/components/colored-blockquote.blade.php', '{{ $class }}');
11201119
$this->publishView('vendor/hyde/components/markdown-heading.blade.php', '{{ $level }}');
11211120

1121+
$this->assertStringContainsString('Build output', Markdown::render("```terminal title=\"Build output\"\nDone!\n```"));
11221122
$this->assertStringContainsString('info', Markdown::render('>info Hello'));
11231123
$this->assertStringContainsString('2', Markdown::render('## Hello'));
11241124
}
@@ -1428,14 +1428,6 @@ protected function classDeclaration(string $class): string
14281428
return trim(implode("\n", $declaration));
14291429
}
14301430

1431-
/** @return array<int, string> */
1432-
protected function constructorParameters(string $class): array
1433-
{
1434-
return array_map(fn ($parameter): string => $parameter->getName(),
1435-
(new ReflectionClass($class))->getConstructor()->getParameters()
1436-
);
1437-
}
1438-
14391431
protected function normalize(string $contents): string
14401432
{
14411433
return str_replace("\r\n", "\n", $contents);

tests/Feature/TerminalCodeBlocksTest.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,25 @@
55
namespace Hyde\Framework\Testing\Feature;
66

77
use Hyde\Framework\Services\MarkdownService;
8+
use Hyde\Markdown\Extensions\TerminalBlockViewModel;
89
use Hyde\Markdown\Extensions\TerminalExtension;
910
use Hyde\Markdown\Extensions\Nodes\TerminalBlock;
1011
use Hyde\Markdown\Extensions\Processing\TerminalBlockRenderer;
1112
use Hyde\Markdown\Extensions\Processing\TransformTerminalBlocks;
1213
use Hyde\Markdown\Models\Markdown;
1314
use Hyde\Testing\TestCase;
1415
use InvalidArgumentException;
16+
use League\CommonMark\Event\DocumentParsedEvent;
17+
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
18+
use League\CommonMark\Node\Block\Document;
1519
use League\CommonMark\Node\Node;
1620
use League\CommonMark\Renderer\ChildNodeRendererInterface;
1721
use Mockery;
1822
use Torchlight\Commonmark\BaseExtension;
1923

2024
#[\PHPUnit\Framework\Attributes\CoversClass(TerminalExtension::class)]
2125
#[\PHPUnit\Framework\Attributes\CoversClass(TerminalBlock::class)]
26+
#[\PHPUnit\Framework\Attributes\CoversClass(TerminalBlockViewModel::class)]
2227
#[\PHPUnit\Framework\Attributes\CoversClass(TerminalBlockRenderer::class)]
2328
#[\PHPUnit\Framework\Attributes\CoversClass(TransformTerminalBlocks::class)]
2429
class TerminalCodeBlocksTest extends TestCase
@@ -260,4 +265,53 @@ public function testTerminalBlocksAreNotSubmittedToTorchlight(): void
260265
$this->assertSame([], BaseExtension::$torchlightBlocks);
261266
$this->assertStringContainsString('<figure class="hyde-terminal ', $html);
262267
}
268+
269+
public function testParsedBlocksCarryTheViewModelTheyWereParsedInto(): void
270+
{
271+
$document = new Document();
272+
273+
$fence = new FencedCode(3, '`', 0);
274+
$fence->setInfo('terminal xml title="Build output"');
275+
$fence->setLiteral('$ php hyde build');
276+
277+
$document->appendChild($fence);
278+
279+
(new TransformTerminalBlocks())(new DocumentParsedEvent($document));
280+
281+
/** @var TerminalBlock $node */
282+
$node = $document->firstChild();
283+
284+
$this->assertInstanceOf(TerminalBlock::class, $node);
285+
$this->assertInstanceOf(TerminalBlockViewModel::class, $node->viewModel);
286+
287+
$this->assertSame('$ php hyde build', $node->viewModel->literal);
288+
$this->assertSame('Build output', $node->viewModel->title);
289+
$this->assertTrue($node->viewModel->usesSymfonyFormatting);
290+
}
291+
292+
public function testViewModelRendersTheTerminalView(): void
293+
{
294+
$html = (new TerminalBlockViewModel('$ php hyde build', 'Build output'))->render();
295+
296+
$this->assertStringContainsString('<figure class="hyde-terminal ', $html);
297+
$this->assertStringContainsString('<span>Build output</span>', $html);
298+
$this->assertStringContainsString('<span class="hyde-terminal-prompt select-none" aria-hidden="true">$ </span>php hyde build', $html);
299+
}
300+
301+
public function testViewModelGivesTheViewTheSameDataAsBefore(): void
302+
{
303+
$viewModel = new TerminalBlockViewModel('$ php hyde build', 'Build output');
304+
305+
$this->assertSame(['contents', 'title'], array_keys((fn (): array => $this->viewData())->call($viewModel)));
306+
}
307+
308+
public function testViewModelContentsAreFinishedMarkup(): void
309+
{
310+
$viewModel = new TerminalBlockViewModel('<info>Ready</info> <b>Bold</b>', usesSymfonyFormatting: true);
311+
312+
$this->assertSame(
313+
'<span class="hyde-terminal-info text-[#C3E88D]">Ready</span> &lt;b&gt;Bold&lt;/b&gt;',
314+
$viewModel->contents
315+
);
316+
}
263317
}

0 commit comments

Comments
 (0)