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

Cecilapp / Cecil / 26407566224

25 May 2026 03:18PM UTC coverage: 82.54%. First build
26407566224

Pull #2383

github

web-flow
Merge ddfaf03b1 into 88004e568
Pull Request #2383: refactor: asset handling and add renderer extensions

282 of 358 new or added lines in 10 files covered. (78.77%)

3503 of 4244 relevant lines covered (82.54%)

0.83 hits per line

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

67.19
/src/Renderer/Extension/Content.php
1
<?php
2

3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <arnaud@ligny.fr>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace Cecil\Renderer\Extension;
15

16
use Cecil\Builder;
17
use Cecil\Collection\Page\Page;
18
use Cecil\Config;
19
use Cecil\Converter\Parsedown;
20
use Cecil\Exception\RuntimeException;
21
use Highlight\Highlighter;
22
use Symfony\Component\Yaml\Exception\ParseException;
23
use Symfony\Component\Yaml\Yaml;
24
use Twig\Extension\AbstractExtension;
25

26
/**
27
 * Content Twig extension.
28
 *
29
 * Provides filters and functions for content processing in Twig templates,
30
 * including text manipulation, Markdown rendering, and data parsing.
31
 */
32
class Content extends AbstractExtension
33
{
34
    /** @var Builder */
35
    protected $builder;
36

37
    /** @var Config */
38
    protected $config;
39

40
    public function __construct(Builder $builder)
41
    {
42
        $this->builder = $builder;
1✔
43
        $this->config = $builder->getConfig();
1✔
44
    }
45

46
    public function getFunctions(): array
47
    {
48
        return [
1✔
49
            new \Twig\TwigFunction('readtime', [$this, 'readtime']),
1✔
50
        ];
1✔
51
    }
52

53
    public function getFilters(): array
54
    {
55
        return [
1✔
56
            new \Twig\TwigFilter('slugify', [$this, 'slugifyFilter']),
1✔
57
            new \Twig\TwigFilter('excerpt', [$this, 'excerpt']),
1✔
58
            new \Twig\TwigFilter('excerpt_html', [$this, 'excerptHtml']),
1✔
59
            new \Twig\TwigFilter('markdown_to_html', [$this, 'markdownToHtml']),
1✔
60
            new \Twig\TwigFilter('toc', [$this, 'markdownToToc']),
1✔
61
            new \Twig\TwigFilter('json_decode', [$this, 'jsonDecode']),
1✔
62
            new \Twig\TwigFilter('yaml_parse', [$this, 'yamlParse']),
1✔
63
            new \Twig\TwigFilter('preg_split', [$this, 'pregSplit']),
1✔
64
            new \Twig\TwigFilter('preg_match_all', [$this, 'pregMatchAll']),
1✔
65
            new \Twig\TwigFilter('hex_to_rgb', [$this, 'hexToRgb']),
1✔
66
            new \Twig\TwigFilter('splitline', [$this, 'splitLine']),
1✔
67
            new \Twig\TwigFilter('iterable', [$this, 'iterable']),
1✔
68
            new \Twig\TwigFilter('highlight', [$this, 'highlight']),
1✔
69
            new \Twig\TwigFilter('unique', [$this, 'unique']),
1✔
70
            // date
71
            new \Twig\TwigFilter('duration_to_iso8601', ['\Cecil\Util\Date', 'durationToIso8601']),
1✔
72
        ];
1✔
73
    }
74

75
    /**
76
     * Slugifies a string.
77
     */
78
    public function slugifyFilter(string $string): string
79
    {
NEW
80
        return Page::slugify($string);
×
81
    }
82

83
    /**
84
     * Reads $length first characters of a string and adds a suffix.
85
     */
86
    public function excerpt(?string $string, int $length = 450, string $suffix = ' …'): string
87
    {
88
        $string = $string ?? '';
1✔
89

90
        $string = str_replace('</p>', '<br><br>', $string);
1✔
91
        $string = trim(strip_tags($string, '<br>'));
1✔
92
        if (mb_strlen($string) > $length) {
1✔
93
            $string = mb_substr($string, 0, $length);
1✔
94
            $string .= $suffix;
1✔
95
        }
96

97
        return $string;
1✔
98
    }
99

100
    /**
101
     * Reads characters before or after '<!-- separator -->'.
102
     * Options:
103
     *  - separator: string to use as separator (`excerpt|break` by default)
104
     *  - capture: part to capture, `before` or `after` the separator (`before` by default).
105
     */
106
    public function excerptHtml(?string $string, array $options = []): string
107
    {
108
        $string = $string ?? '';
1✔
109

110
        $separator = (string) $this->config->get('pages.body.excerpt.separator');
1✔
111
        $capture = (string) $this->config->get('pages.body.excerpt.capture');
1✔
112
        extract($options, EXTR_IF_EXISTS);
1✔
113

114
        // https://regex101.com/r/n9TWHF/1
115
        $pattern = '(.*)<!--[[:blank:]]?(' . $separator . ')[[:blank:]]?-->(.*)';
1✔
116
        preg_match('/' . $pattern . '/is', $string, $matches);
1✔
117

118
        if (empty($matches)) {
1✔
NEW
119
            return $string;
×
120
        }
121
        $result = trim($matches[1]);
1✔
122
        if ($capture == 'after') {
1✔
123
            $result = trim($matches[3]);
1✔
124
        }
125
        // removes footnotes and returns result
126
        return preg_replace('/<sup[^>]*>[^u]*<\/sup>/', '', $result);
1✔
127
    }
128

129
    /**
130
     * Converts a Markdown string to HTML.
131
     *
132
     * @throws RuntimeException
133
     */
134
    public function markdownToHtml(?string $markdown): ?string
135
    {
136
        $markdown = $markdown ?? '';
1✔
137

138
        try {
139
            $parsedown = new Parsedown($this->builder);
1✔
140
            $html = $parsedown->text($markdown);
1✔
NEW
141
        } catch (\Exception $e) {
×
NEW
142
            throw new RuntimeException(
×
NEW
143
                '"markdown_to_html" filter can not convert supplied Markdown.',
×
NEW
144
                previous: $e
×
NEW
145
            );
×
146
        }
147

148
        return $html;
1✔
149
    }
150

151
    /**
152
     * Extracts only headings matching the given `selectors` (h2, h3, etc.),
153
     * or those defined in config `pages.body.toc` if not specified.
154
     * The `format` parameter defines the output format: `html` or `json`.
155
     * The `url` parameter is used to build links to headings.
156
     *
157
     * @throws RuntimeException
158
     */
159
    public function markdownToToc(?string $markdown, $format = 'html', ?array $selectors = null, string $url = ''): ?string
160
    {
161
        $markdown = $markdown ?? '';
1✔
162
        $selectors = $selectors ?? (array) $this->config->get('pages.body.toc');
1✔
163

164
        try {
165
            $parsedown = new Parsedown($this->builder, ['selectors' => $selectors, 'base_url' => $url]);
1✔
166
            $parsedown->body($markdown);
1✔
167
            $return = $parsedown->contentsList($format);
1✔
NEW
168
        } catch (\Exception) {
×
NEW
169
            throw new RuntimeException('"toc" filter can not convert supplied Markdown.');
×
170
        }
171

172
        return $return;
1✔
173
    }
174

175
    /**
176
     * Converts a JSON string to an array.
177
     *
178
     * @throws RuntimeException
179
     */
180
    public function jsonDecode(?string $json): ?array
181
    {
182
        $json = $json ?? '';
1✔
183

184
        try {
185
            $array = json_decode($json, true);
1✔
186
            if ($array === null && json_last_error() !== JSON_ERROR_NONE) {
1✔
187
                throw new \Exception('JSON error.');
1✔
188
            }
NEW
189
        } catch (\Exception) {
×
NEW
190
            throw new RuntimeException('"json_decode" filter can not parse supplied JSON.');
×
191
        }
192

193
        return $array;
1✔
194
    }
195

196
    /**
197
     * Converts a YAML string to an array.
198
     *
199
     * @throws RuntimeException
200
     */
201
    public function yamlParse(?string $yaml): ?array
202
    {
203
        $yaml = $yaml ?? '';
1✔
204

205
        try {
206
            $array = Yaml::parse($yaml, Yaml::PARSE_DATETIME);
1✔
207
            if (!\is_array($array)) {
1✔
208
                throw new ParseException('YAML error.');
1✔
209
            }
NEW
210
        } catch (ParseException $e) {
×
NEW
211
            throw new RuntimeException(\sprintf('"yaml_parse" filter can not parse supplied YAML: %s', $e->getMessage()));
×
212
        }
213

214
        return $array;
1✔
215
    }
216

217
    /**
218
     * Split a string into an array using a regular expression.
219
     *
220
     * @throws RuntimeException
221
     */
222
    public function pregSplit(?string $value, string $pattern, int $limit = 0): ?array
223
    {
NEW
224
        $value = $value ?? '';
×
225

226
        try {
NEW
227
            $array = preg_split($pattern, $value, $limit);
×
NEW
228
            if ($array === false) {
×
NEW
229
                throw new RuntimeException('PREG split error.');
×
230
            }
NEW
231
        } catch (\Exception) {
×
NEW
232
            throw new RuntimeException('"preg_split" filter can not split supplied string.');
×
233
        }
234

NEW
235
        return $array;
×
236
    }
237

238
    /**
239
     * Perform a regular expression match and return the group for all matches.
240
     *
241
     * @throws RuntimeException
242
     */
243
    public function pregMatchAll(?string $value, string $pattern, int $group = 0): ?array
244
    {
NEW
245
        $value = $value ?? '';
×
246

247
        try {
NEW
248
            $array = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER);
×
NEW
249
            if ($array === false) {
×
NEW
250
                throw new RuntimeException('PREG match all error.');
×
251
            }
NEW
252
        } catch (\Exception) {
×
NEW
253
            throw new RuntimeException('"preg_match_all" filter can not match in supplied string.');
×
254
        }
255

NEW
256
        return $matches[$group];
×
257
    }
258

259
    /**
260
     * Calculates estimated time to read a text.
261
     */
262
    public function readtime(?string $text): string
263
    {
264
        $text = $text ?? '';
1✔
265

266
        $words = str_word_count(strip_tags($text));
1✔
267
        $min = floor($words / 200);
1✔
268
        if ($min === 0) {
1✔
NEW
269
            return '1';
×
270
        }
271

272
        return (string) $min;
1✔
273
    }
274

275
    /**
276
     * Converts an hexadecimal color to RGB.
277
     *
278
     * @throws RuntimeException
279
     */
280
    public function hexToRgb(?string $variable): array
281
    {
282
        $variable = $variable ?? '';
1✔
283

284
        if (!self::isHex($variable)) {
1✔
NEW
285
            throw new RuntimeException(\sprintf('"%s" is not a valid hexadecimal value.', $variable));
×
286
        }
287
        $hex = ltrim($variable, '#');
1✔
288
        if (\strlen($hex) == 3) {
1✔
NEW
289
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
×
290
        }
291
        $c = hexdec($hex);
1✔
292

293
        return [
1✔
294
            'red'   => $c >> 16 & 0xFF,
1✔
295
            'green' => $c >> 8 & 0xFF,
1✔
296
            'blue'  => $c & 0xFF,
1✔
297
        ];
1✔
298
    }
299

300
    /**
301
     * Split a string in multiple lines.
302
     */
303
    public function splitLine(?string $variable, int $max = 18): array
304
    {
305
        $variable = $variable ?? '';
1✔
306

307
        return preg_split("/.{0,{$max}}\K(\s+|$)/", $variable, 0, PREG_SPLIT_NO_EMPTY);
1✔
308
    }
309

310
    /**
311
     * Converts a variable to an iterable (array).
312
     */
313
    public function iterable($value): array
314
    {
315
        if (\is_array($value)) {
1✔
316
            return $value;
1✔
317
        }
NEW
318
        if (\is_string($value)) {
×
NEW
319
            return [$value];
×
320
        }
NEW
321
        if ($value instanceof \Traversable) {
×
NEW
322
            return iterator_to_array($value);
×
323
        }
NEW
324
        if ($value instanceof \stdClass) {
×
NEW
325
            return (array) $value;
×
326
        }
NEW
327
        if (\is_object($value)) {
×
NEW
328
            return [$value];
×
329
        }
NEW
330
        if (\is_int($value) || \is_float($value)) {
×
NEW
331
            return [$value];
×
332
        }
NEW
333
        return [$value];
×
334
    }
335

336
    /**
337
     * Highlights a code snippet.
338
     */
339
    public function highlight(string $code, string $language): string
340
    {
NEW
341
        return (new Highlighter())->highlight($language, $code)->value;
×
342
    }
343

344
    /**
345
     * Returns an array with unique values.
346
     */
347
    public function unique(array $array): array
348
    {
349
        return array_intersect_key($array, array_unique(array_map('strtolower', $array), SORT_STRING));
1✔
350
    }
351

352
    /**
353
     * Is a hexadecimal color is valid?
354
     */
355
    private static function isHex(string $hex): bool
356
    {
357
        $valid = \is_string($hex);
1✔
358
        $hex = ltrim($hex, '#');
1✔
359
        $length = \strlen($hex);
1✔
360
        $valid = $valid && ($length === 3 || $length === 6);
1✔
361
        $valid = $valid && ctype_xdigit($hex);
1✔
362

363
        return $valid;
1✔
364
    }
365
}
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