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

dg / texy / 21501721037

30 Jan 2026 02:00AM UTC coverage: 91.159% (-1.3%) from 92.426%
21501721037

push

github

dg
wip

2681 of 2941 relevant lines covered (91.16%)

0.91 hits per line

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

93.64
/src/Texy/Modules/HeadingModule.php
1
<?php
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
declare(strict_types=1);
9

10
namespace Texy\Modules;
11

12
use Texy;
13
use Texy\Modifier;
14
use Texy\Nodes\HeadingNode;
15
use Texy\Nodes\HeadingType;
16
use Texy\ParseContext;
17
use Texy\Position;
18
use Texy\Syntax;
19
use function array_flip, array_values, asort, max, min, rtrim, strlen, trim;
20

21

22
/**
23
 * Processes heading syntax (underlined and surrounded) and builds table of contents.
24
 */
25
final class HeadingModule extends Texy\Module
26
{
27
        public const
28
                DYNAMIC = 1, // auto-leveling
29
                FIXED = 2; // fixed-leveling
30

31
        /** textual content of first heading */
32
        public ?string $title = null;
33

34
        /** @var list<array{node: HeadingNode, title?: string}>  generated Table of Contents */
35
        public array $TOC = [];
36

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

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

43
        /** balancing mode */
44
        public int $balancing = self::DYNAMIC;
45

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

54
        /** generate ID for headings? */
55
        public bool $generateID = false;
56

57
        /** prefix for autogenerated IDs */
58
        public string $idPrefix = 'toc-';
59

60
        /** @var array<string, true>  used ID's */
61
        private array $usedID = [];
62

63

64
        public function __construct(
1✔
65
                private Texy\Texy $texy,
66
        ) {
67
                $texy->addHandler('afterParse', $this->afterParse(...));
1✔
68
        }
1✔
69

70

71
        public function beforeParse(string &$text): void
1✔
72
        {
73
                $this->texy->registerBlockPattern(
1✔
74
                        $this->parseUnderline(...),
1✔
75
                        '~^
76
                                ( \S .{0,1000} )                 # heading text (1)
77
                                ' . Texy\Patterns::MODIFIER_H . '? # modifier (2)
1✔
78
                                \n
79
                                ( \#{3,}+ | \*{3,}+ | ={3,}+ | -{3,}+ )  # underline characters (3)
80
                        $~mU',
81
                        Syntax::HeadingUnderlined,
1✔
82
                );
83

84
                $this->texy->registerBlockPattern(
1✔
85
                        $this->parseSurround(...),
1✔
86
                        '~^
87
                                ( \#{2,}+ | ={2,}+ )             # opening characters (1)
88
                                (.+)                             # heading text (2)
89
                                ' . Texy\Patterns::MODIFIER_H . '? # modifier (2)
1✔
90
                        $~mU',
91
                        Syntax::HeadingSurrounded,
1✔
92
                );
93

94
                $this->title = null;
1✔
95
                $this->usedID = [];
1✔
96
                $this->TOC = [];
1✔
97
        }
1✔
98

99

100
        /**
101
         * Post-process AST headings - apply balancing and calculate final levels.
102
         */
103
        public function afterParse(Texy\Nodes\DocumentNode $document): void
1✔
104
        {
105
                // Collect all heading nodes (separated: dynamic balancing vs fixed balancing)
106
                [$dynamicHeadings, $fixedHeadings] = $this->collectHeadings($document);
1✔
107

108
                // Apply fixed balancing to texysource headings (just add top)
109
                foreach ($fixedHeadings as $node) {
1✔
110
                        $node->level = min(6, max(1, $node->level + $this->top));
×
111
                }
112

113
                // Process main document headings
114
                $headings = $dynamicHeadings;
1✔
115
                if (!$headings) {
1✔
116
                        // Still need to process ID generation for fixed headings
117
                        $headings = $fixedHeadings;
1✔
118
                        if (!$headings) {
1✔
119
                                return;
1✔
120
                        }
121
                } elseif ($this->balancing === self::DYNAMIC) {
1✔
122
                        $map = [];
1✔
123
                        $min = 100;
1✔
124

125
                        // First pass: collect level information
126
                        foreach ($headings as $node) {
1✔
127
                                $level = $node->level;
1✔
128
                                if ($node->type === HeadingType::Surrounded) {
1✔
129
                                        $min = min($level, $min);
1✔
130
                                } elseif ($node->type === HeadingType::Underlined) {
1✔
131
                                        $map[$level] = $level;
1✔
132
                                }
133
                        }
134

135
                        // Calculate top offset for surrounded headings
136
                        $top = $this->top - $min;
1✔
137

138
                        // Sort underlined levels and create mapping
139
                        asort($map);
1✔
140
                        $map = array_flip(array_values($map));
1✔
141

142
                        // Second pass: apply calculated levels
143
                        foreach ($headings as $node) {
1✔
144
                                $level = match ($node->type) {
1✔
145
                                        HeadingType::Surrounded => $node->level + $top,
1✔
146
                                        HeadingType::Underlined => $map[$node->level] + $this->top,
1✔
147
                                };
148
                                $node->level = min(6, max(1, $level));
1✔
149
                        }
150
                } else {
151
                        // FIXED balancing - just add top
152
                        foreach ($headings as $node) {
×
153
                                $node->level = min(6, max(1, $node->level + $this->top));
×
154
                        }
155
                }
156

157
                // Generate IDs if enabled (only for main document headings)
158
                if ($this->generateID) {
1✔
159
                        foreach ($dynamicHeadings as $node) {
1✔
160
                                // Check for custom TOC title in style
161
                                $tocTitle = $node->modifier?->styles['toc'] ?? null;
1✔
162
                                if ($tocTitle !== null) {
1✔
163
                                        unset($node->modifier->styles['toc']);
1✔
164
                                        $title = $tocTitle;
1✔
165
                                } else {
166
                                        $title = trim(Texy\Helpers::extractText($node));
1✔
167
                                }
168

169
                                // Skip if ID already set in modifier
170
                                if ($node->modifier?->id) {
1✔
171
                                        $this->usedID[$node->modifier->id] = true;
×
172

173
                                        continue;
×
174
                                }
175

176
                                $id = $this->idPrefix . Texy\Helpers::webalize($title);
1✔
177
                                $counter = '';
1✔
178
                                if (isset($this->usedID[$id . $counter])) {
1✔
179
                                        $counter = 2;
1✔
180
                                        while (isset($this->usedID[$id . '-' . $counter])) {
1✔
181
                                                $counter++;
1✔
182
                                        }
183

184
                                        $id .= '-' . $counter;
1✔
185
                                }
186

187
                                $this->usedID[$id] = true;
1✔
188

189
                                // Create modifier if not exists
190
                                if ($node->modifier === null) {
1✔
191
                                        $node->modifier = new Modifier;
1✔
192
                                }
193

194
                                $node->modifier->id = $id;
1✔
195

196
                        }
197
                }
198

199
                // Set document title from first heading (main document only)
200
                if ($this->title === null && $dynamicHeadings) {
1✔
201
                        $this->title = trim(Texy\Helpers::extractText($dynamicHeadings[0]));
1✔
202
                }
203

204
                // Build TOC (only for main document headings)
205
                foreach ($dynamicHeadings as $node) {
1✔
206
                        $entry = ['node' => $node];
1✔
207
                        if ($this->generateID) {
1✔
208
                                $entry['title'] = trim(Texy\Helpers::extractText($node));
1✔
209
                        }
210

211
                        $this->TOC[] = $entry;
1✔
212
                }
213
        }
1✔
214

215

216
        /**
217
         * Parses underlined heading.
218
         * @param  array<?string>  $matches
219
         * @param  array<?int>  $offsets
220
         */
221
        public function parseUnderline(ParseContext $context, array $matches, array $offsets): HeadingNode
1✔
222
        {
223
                [, $mContent, $mMod, $mLine] = $matches;
1✔
224
                $level = $this->levels[$mLine[0]];
1✔
225
                $contentOffset = $offsets[1] ?? $offsets[0];
1✔
226
                return new HeadingNode(
1✔
227
                        $context->parseInline(trim($mContent), $contentOffset),
1✔
228
                        $level,
229
                        HeadingType::Underlined,
1✔
230
                        Modifier::parse($mMod),
1✔
231
                        new Position($offsets[0], strlen($matches[0])),
1✔
232
                );
233
        }
234

235

236
        /**
237
         * Parses surrounded heading.
238
         * @param  array<?string>  $matches
239
         * @param  array<?int>  $offsets
240
         */
241
        public function parseSurround(ParseContext $context, array $matches, array $offsets): HeadingNode
1✔
242
        {
243
                [, $mLine, $mContent, $mMod] = $matches;
1✔
244
                $level = min(7, max(2, strlen($mLine)));
1✔
245
                $level = $this->moreMeansHigher ? 7 - $level : $level - 2;
1✔
246
                $mContent = rtrim($mContent, $mLine[0] . ' ');
1✔
247
                $contentOffset = $offsets[2] ?? $offsets[0];
1✔
248

249
                // Adjust offset for leading whitespace removed by trim
250
                $trimmed = ltrim($mContent);
1✔
251
                $leadingSpaces = strlen($mContent) - strlen($trimmed);
1✔
252
                $contentOffset += $leadingSpaces;
1✔
253

254
                return new HeadingNode(
1✔
255
                        $context->parseInline(trim($mContent), $contentOffset),
1✔
256
                        $level,
257
                        HeadingType::Surrounded,
1✔
258
                        Modifier::parse($mMod),
1✔
259
                        new Position($offsets[0], strlen($matches[0])),
1✔
260
                );
261
        }
262

263

264
        /**
265
         * Collect all HeadingNode from AST.
266
         * Headings inside texysource sections are collected separately (for fixed balancing).
267
         * @return array{list<HeadingNode>, list<HeadingNode>}  [headings for dynamic balancing, headings for fixed balancing]
268
         */
269
        private function collectHeadings(Texy\Node $node, bool $inTexysource = false): array
1✔
270
        {
271
                // Check if entering texysource section
272
                if ($node instanceof Texy\Nodes\SectionNode && $node->type === 'texysource') {
1✔
273
                        $inTexysource = true;
×
274
                }
275

276
                $dynamic = [];
1✔
277
                $fixed = [];
1✔
278

279
                if ($node instanceof HeadingNode) {
1✔
280
                        if ($inTexysource) {
1✔
281
                                $fixed[] = $node;
×
282
                        } else {
283
                                $dynamic[] = $node;
1✔
284
                        }
285
                }
286

287
                foreach ($node->getNodes() as $child) {
1✔
288
                        [$childDynamic, $childFixed] = $this->collectHeadings($child, $inTexysource);
1✔
289
                        $dynamic = [...$dynamic, ...$childDynamic];
1✔
290
                        $fixed = [...$fixed, ...$childFixed];
1✔
291
                }
292

293
                return [$dynamic, $fixed];
1✔
294
        }
295
}
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