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

dg / texy / 22262497275

21 Feb 2026 07:01PM UTC coverage: 93.057% (+0.7%) from 92.367%
22262497275

push

github

dg
added CLAUDE.md

2426 of 2607 relevant lines covered (93.06%)

0.93 hits per line

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

98.82
/src/Texy/Modules/HeadingModule.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Texy! (https://texy.nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Texy\Modules;
9

10
use Texy;
11
use Texy\Modifier;
12
use function array_flip, array_values, asort, count, is_array, max, min, reset, rtrim, strlen, trim;
13

14

15
/**
16
 * Processes heading syntax (underlined and surrounded) and builds table of contents.
17
 */
18
final class HeadingModule extends Texy\Module
19
{
20
        public const
21
                DYNAMIC = 1, // auto-leveling
22
                FIXED = 2; // fixed-leveling
23

24
        /** textual content of first heading */
25
        public ?string $title = null;
26

27
        /** @var list<array{el: Texy\HtmlElement, level: int, type: string, title?: string}>  generated Table of Contents */
28
        public array $TOC = [];
29

30
        public bool $generateID = false;
31

32
        /** prefix for autogenerated ID */
33
        public string $idPrefix = 'toc-';
34

35
        /** level of top heading, 1..6 */
36
        public int $top = 1;
37

38
        /** surrounded headings: more #### means higher heading */
39
        public bool $moreMeansHigher = true;
40

41
        /** balancing mode */
42
        public int $balancing = self::DYNAMIC;
43

44
        /** @var array<string, int>  when $balancing = HeadingModule::FIXED */
45
        public array $levels = [
46
                '#' => 0, // # --> $levels['#'] + $top = 0 + 1 = 1 --> <h1> ... </h1>
47
                '*' => 1,
48
                '=' => 2,
49
                '-' => 3,
50
        ];
51

52
        /** @var array<string, true>  used ID's */
53
        private array $usedID = [];
54

55

56
        public function __construct(Texy\Texy $texy)
1✔
57
        {
58
                $this->texy = $texy;
1✔
59

60
                $texy->addHandler('heading', $this->solve(...));
1✔
61
                $texy->addHandler('beforeParse', $this->beforeParse(...));
1✔
62
                $texy->addHandler('afterParse', $this->afterParse(...));
1✔
63

64
                $texy->registerBlockPattern(
1✔
65
                        $this->patternUnderline(...),
1✔
66
                        '#^(\S.{0,1000})' . Texy\Patterns::MODIFIER_H . '?\n'
67
                        . '(\#{3,}+|\*{3,}+|={3,}+|-{3,}+)$#mU',
1✔
68
                        'heading/underlined',
1✔
69
                );
70

71
                $texy->registerBlockPattern(
1✔
72
                        $this->patternSurround(...),
1✔
73
                        '#^(\#{2,}+|={2,}+)(.+)' . Texy\Patterns::MODIFIER_H . '?()$#mU',
1✔
74
                        'heading/surrounded',
1✔
75
                );
76
        }
1✔
77

78

79
        private function beforeParse(): void
80
        {
81
                $this->title = null;
1✔
82
                $this->usedID = [];
1✔
83
                $this->TOC = [];
1✔
84
        }
1✔
85

86

87
        private function afterParse(Texy\Texy $texy, Texy\HtmlElement $DOM, bool $isSingleLine): void
1✔
88
        {
89
                if ($isSingleLine) {
1✔
90
                        return;
×
91
                }
92

93
                if ($this->balancing === self::DYNAMIC) {
1✔
94
                        $top = $this->top;
1✔
95
                        $map = [];
1✔
96
                        $min = 100;
1✔
97
                        foreach ($this->TOC as $item) {
1✔
98
                                $level = $item['level'];
1✔
99
                                if ($item['type'] === 'surrounded') {
1✔
100
                                        $min = min($level, $min);
1✔
101
                                        $top = $this->top - $min;
1✔
102

103
                                } elseif ($item['type'] === 'underlined') {
1✔
104
                                        $map[$level] = $level;
1✔
105
                                }
106
                        }
107

108
                        asort($map);
1✔
109
                        $map = array_flip(array_values($map));
1✔
110
                }
111

112
                foreach ($this->TOC as $key => $item) {
1✔
113
                        if ($this->balancing === self::DYNAMIC) {
1✔
114
                                if ($item['type'] === 'surrounded') {
1✔
115
                                        $level = $item['level'] + $top;
1✔
116

117
                                } elseif ($item['type'] === 'underlined') {
1✔
118
                                        $level = $map[$item['level']] + $this->top;
1✔
119

120
                                } else {
121
                                        $level = $item['level'];
1✔
122
                                }
123

124
                                $item['el']->setName('h' . min(6, max(1, $level)));
1✔
125
                                $this->TOC[$key]['level'] = $level;
1✔
126
                        }
127

128
                        if ($this->generateID) {
1✔
129
                                $style = $item['el']->attrs['style'] ?? null;
1✔
130
                                if (is_array($style) && !empty($style['toc'])) {
1✔
131
                                        $title = (string) $style['toc'];
1✔
132
                                        unset($item['el']->attrs['style']['toc']);
1✔
133
                                } else {
134
                                        $title = trim($item['el']->toText($this->texy));
1✔
135
                                }
136

137
                                $this->TOC[$key]['title'] = $title;
1✔
138
                                if (empty($item['el']->attrs['id'])) {
1✔
139
                                        $id = $this->idPrefix . Texy\Helpers::webalize($title);
1✔
140
                                        $counter = '';
1✔
141
                                        if (isset($this->usedID[$id . $counter])) {
1✔
142
                                                $counter = 2;
1✔
143
                                                while (isset($this->usedID[$id . '-' . $counter])) {
1✔
144
                                                        $counter++;
1✔
145
                                                }
146

147
                                                $id .= '-' . $counter;
1✔
148
                                        }
149

150
                                        $this->usedID[$id] = true;
1✔
151
                                        $item['el']->attrs['id'] = $id;
1✔
152
                                }
153
                        }
154
                }
155

156
                // document title
157
                if ($this->title === null && count($this->TOC)) {
1✔
158
                        $item = reset($this->TOC);
1✔
159
                        $this->title = $item['title'] ?? trim($item['el']->toText($this->texy));
1✔
160
                }
161
        }
1✔
162

163

164
        /**
165
         * Callback for underlined heading.
166
         *
167
         * Heading .(title)[class]{style}>
168
         * -------------------------------
169
         * @param  string[]  $matches
170
         */
171
        public function patternUnderline(Texy\BlockParser $parser, array $matches): Texy\HtmlElement|string|null
1✔
172
        {
173
                [, $mContent, $mMod, $mLine] = $matches;
1✔
174
                // $matches:
175
                // [1] => ...
176
                // [2] => .(title)[class]{style}<>
177
                // [3] => ...
178

179
                $mod = new Modifier($mMod);
1✔
180
                $level = $this->levels[$mLine[0]];
1✔
181
                return $this->texy->invokeAroundHandlers('heading', $parser, [$level, $mContent, $mod, false]);
1✔
182
        }
183

184

185
        /**
186
         * Callback for surrounded heading.
187
         *
188
         * ### Heading .(title)[class]{style}>
189
         * @param  string[]  $matches
190
         */
191
        public function patternSurround(Texy\BlockParser $parser, array $matches): Texy\HtmlElement|string|null
1✔
192
        {
193
                [, $mLine, $mContent, $mMod] = $matches;
1✔
194
                // [1] => ###
195
                // [2] => ...
196
                // [3] => .(title)[class]{style}<>
197

198
                $mod = new Modifier($mMod);
1✔
199
                $level = min(7, max(2, strlen($mLine)));
1✔
200
                $level = $this->moreMeansHigher ? 7 - $level : $level - 2;
1✔
201
                $mContent = rtrim($mContent, $mLine[0] . ' ');
1✔
202
                return $this->texy->invokeAroundHandlers('heading', $parser, [$level, $mContent, $mod, true]);
1✔
203
        }
204

205

206
        /**
207
         * Finish invocation.
208
         */
209
        private function solve(
1✔
210
                Texy\HandlerInvocation $invocation,
211
                int $level,
212
                string $content,
213
                Modifier $mod,
214
                bool $isSurrounded,
215
        ): Texy\HtmlElement
216
        {
217
                // as fixed balancing, for block/texysource & correct decorating
218
                $el = new Texy\HtmlElement('h' . min(6, max(1, $level + $this->top)));
1✔
219
                $mod->decorate($this->texy, $el);
1✔
220

221
                $el->parseLine($this->texy, trim($content));
1✔
222

223
                $this->TOC[] = [
1✔
224
                        'el' => $el,
1✔
225
                        'level' => $level,
1✔
226
                        'type' => $isSurrounded ? 'surrounded' : 'underlined',
1✔
227
                ];
228

229
                return $el;
1✔
230
        }
231
}
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