• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

un-zero-un / Isocontent / 19885920020

03 Dec 2025 07:29AM UTC coverage: 94.505% (+0.8%) from 93.75%
19885920020

push

gha

344 of 364 relevant lines covered (94.51%)

130.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.11
/src/Renderer/HTMLRenderer.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Isocontent\Renderer;
6

7
use Isocontent\AST\BlockNode;
8
use Isocontent\AST\Node;
9
use Isocontent\AST\NodeList;
10
use Isocontent\AST\TextNode;
11
use Isocontent\Specs\BlockArgumentMatch;
12
use Isocontent\Specs\BlockTypeMatch;
13
use Isocontent\Specs\Specification;
14

15
final class HTMLRenderer implements Renderer
16
{
17
    /**
18
     * @var list<array{0: Specification, 1: string, 2?: string[]}>
19
     */
20
    private array $tags;
21

22
    private array $allowedAttributes = [];
23

24
    /**
25
     * @param list<array{0: Specification, 1: string, 2?: string[]}> $tags
304✔
26
     */
27
    public function __construct(?array $tags = null)
304✔
28
    {
304✔
29
        $this->tags = $tags ?? [
304✔
30
            [new BlockTypeMatch('paragraph'), 'p'],
304✔
31
            [new BlockTypeMatch('inline_text'), 'span'],
304✔
32
            [new BlockTypeMatch('emphasis'), 'em'],
304✔
33
            [new BlockTypeMatch('strong'), 'strong'],
304✔
34
            [new BlockTypeMatch('generic'), 'span'],
304✔
35
            [(new BlockTypeMatch('list'))->and(new BlockArgumentMatch('ordered', false)), 'ul'],
304✔
36
            [(new BlockTypeMatch('list'))->and(new BlockArgumentMatch('ordered', true)), 'ol'],
304✔
37
            [new BlockTypeMatch('list_item'), 'li'],
304✔
38
            [(new BlockTypeMatch('title'))->and(new BlockArgumentMatch('level', 1)), 'h1'],
304✔
39
            [(new BlockTypeMatch('title'))->and(new BlockArgumentMatch('level', 2)), 'h2'],
304✔
40
            [(new BlockTypeMatch('title'))->and(new BlockArgumentMatch('level', 3)), 'h3'],
304✔
41
            [(new BlockTypeMatch('title'))->and(new BlockArgumentMatch('level', 4)), 'h4'],
304✔
42
            [(new BlockTypeMatch('title'))->and(new BlockArgumentMatch('level', 5)), 'h5'],
304✔
43
            [(new BlockTypeMatch('title'))->and(new BlockArgumentMatch('level', 6)), 'h6'],
304✔
44
            [new BlockTypeMatch('quote'), 'blockquote'],
304✔
45
            [new BlockTypeMatch('new_line'), 'br'],
304✔
46
            [new BlockTypeMatch('link'), 'a', ['download', 'href', 'rel', 'target']],
304✔
47
            [new BlockTypeMatch('stripped'), 'del'],
304✔
48
            [new BlockTypeMatch('separator'), 'hr'],
304✔
49
            [new BlockTypeMatch('subscript'), 'sub'],
304✔
50
            [new BlockTypeMatch('superscript'), 'sup'],
304✔
51
            [new BlockTypeMatch('code'), 'code'],
52
        ];
53
    }
184✔
54

55
    #[\Override]
56
    public function render(NodeList $ast): string
184✔
57
    {
184✔
58
        return array_reduce(
184✔
59
            $ast->nodes,
184✔
60
            function (string $memo, Node $node) {
184✔
61
                if ($node instanceof TextNode) {
62
                    return $memo.htmlentities($node->getValue());
63
                }
176✔
64

176✔
65
                if ($node instanceof BlockNode) {
66
                    return $memo.$this->renderBlockNode($node);
67
                }
×
68

184✔
69
                throw new \RuntimeException('Unsupported node type: '.get_class($node));
184✔
70
            },
184✔
71
            ''
72
        );
73
    }
88✔
74

75
    #[\Override]
76
    public function supportsFormat(string $format): bool
88✔
77
    {
78
        return 'html' === $format;
79
    }
176✔
80

81
    private function renderBlockNode(BlockNode $blockNode): string
176✔
82
    {
176✔
83
        $tagName = 'span';
176✔
84
        $allowedAttributes = $this->allowedAttributes;
176✔
85
        foreach ($this->tags as $tag) {
86
            if ($tag[0]->isSatisfiedBy($blockNode)) {
87
                $tagName = $tag[1];
88
                $allowedAttributes = [...$allowedAttributes, ...($tag[2] ?? [])];
176✔
89

32✔
90
                break;
91
            }
92
        }
176✔
93

176✔
94
        $tagAttrs = '';
176✔
95
        foreach ($blockNode->getArguments() as $name => $value) {
176✔
96
            if (!in_array($name, $allowedAttributes, true)) {
176✔
97
                continue;
176✔
98
            }
176✔
99

100
            if (is_bool($value)) {
101
                $tagAttrs .= $value ? (' '.$name) : '';
102

103
                continue;
104
            }
105

106
            $tagAttrs .= ' '.$name.'="'.htmlentities((string) $value).'"';
107
        }
108

109
        if (null === $blockNode->getChildren()) {
110
            return strtr('<:tagName::tagAttrs: />', [':tagName:' => $tagName, ':tagAttrs:' => $tagAttrs]);
111
        }
112

113
        return strtr(
114
            '<:tagName::tagAttrs:>:content:</:tagName:>',
115
            [
116
                ':tagName:' => $tagName,
117
                ':content:' => $this->render($blockNode->getChildren()),
118
                ':tagAttrs:' => $tagAttrs,
119
            ]
120
        );
121
    }
122
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc