Render::init()   A
last analyzed

Complexity

Conditions 5
Paths 6

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 10.3999

Importance

Changes 0
Metric Value
cc 5
eloc 9
nc 6
nop 1
dl 0
loc 17
ccs 4
cts 10
cp 0.4
crap 10.3999
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
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\Step\Pages;
15
16
use Cecil\Builder;
17
use Cecil\Collection\Page\Collection;
18
use Cecil\Collection\Page\Page;
19
use Cecil\Exception\ConfigException;
20
use Cecil\Exception\RuntimeException;
21
use Cecil\Renderer\Config;
22
use Cecil\Renderer\Layout;
23
use Cecil\Renderer\Site;
24
use Cecil\Renderer\Twig;
25
use Cecil\Step\AbstractStep;
26
use Cecil\Util;
27
28
/**
29
 * Render step.
30
 *
31
 * This step is responsible for rendering pages using Twig templates.
32
 * It processes each page, applies the appropriate templates, and generates
33
 * the final output formats. It also handles subsets of pages if specified,
34
 * and adds global variables to the renderer. The rendered pages are then
35
 * stored in the builder's pages collection for further processing or output.
36
 */
37
class Render extends AbstractStep
38
{
39
    public const TMP_DIR = '.cecil';
40
41
    protected $subset = [];
42
43
    /**
44
     * {@inheritdoc}
45
     */
46 1
    public function getName(): string
47
    {
48 1
        return 'Rendering pages';
49
    }
50
51
    /**
52
     * {@inheritdoc}
53
     */
54 1
    public function init(array $options): void
55
    {
56 1
        if (!is_dir($this->config->getLayoutsPath()) && !$this->config->hasTheme()) {
57
            $message = \sprintf('"%s" is not a valid layouts directory', $this->config->getLayoutsPath());
58
            $this->builder->getLogger()->debug($message);
59
        }
60
61
        // render a subset of pages?
62 1
        if (!empty($options['render-subset'])) {
63
            $subset = \sprintf('pages.subsets.%s', (string) $options['render-subset']);
64
            if (!$this->config->has($subset)) {
65
                throw new ConfigException(\sprintf('Subset "%s" not found.', $subset));
66
            }
67
            $this->subset = (array) $this->config->get($subset);
68
        }
69
70 1
        $this->canProcess = true;
71
    }
72
73
    /**
74
     * {@inheritdoc}
75
     *
76
     * @throws RuntimeException
77
     */
78 1
    public function process(): void
79
    {
80
        // prepares renderer
81 1
        $this->builder->setRenderer(new Twig($this->builder, $this->getAllLayoutsPaths()));
82
83
        // adds global variables
84 1
        $this->addGlobals();
85
86 1
        $subset = $this->subset;
87
88
        /** @var Collection $pages */
89 1
        $pages = $this->builder->getPages()
90 1
            // published only
91 1
            ->filter(function (Page $page) {
92 1
                return (bool) $page->getVariable('published');
93 1
            })
94 1
            ->filter(function (Page $page) use ($subset) {
95 1
                if (empty($subset)) {
96 1
                    return true;
97
                }
98
                if (
99
                    !empty($subset['path'])
100
                    && !((bool) preg_match('/' . (string) $subset['path'] . '/i', $page->getPath()))
101
                ) {
102
                    return false;
103
                }
104
                if (!empty($subset['language'])) {
105
                    $language = $page->getVariable('language', $this->config->getLanguageDefault());
106
                    if ($language !== (string) $subset['language']) {
107
                        return false;
108
                    }
109
                }
110
                return true;
111 1
            })
112 1
            // enrichs some variables
113 1
            ->map(function (Page $page) {
114 1
                $formats = $this->getOutputFormats($page);
115
                // output formats
116 1
                $page->setVariable('output', $formats);
117
                // alternates formats
118 1
                $page->setVariable('alternates', $this->getAlternates($formats));
119
                // translations
120 1
                $page->setVariable('translations', $this->getTranslations($page));
121
122 1
                return $page;
123 1
            });
124 1
        $total = \count($pages);
125
126
        // renders each page
127 1
        $count = 0;
128 1
        $postprocessors = [];
129 1
        foreach ((array) $this->config->get('output.postprocessors') as $name => $postprocessor) {
130
            try {
131 1
                if (!class_exists($postprocessor)) {
132 1
                    throw new RuntimeException(\sprintf('Class "%s" not found', $postprocessor));
133
                }
134 1
                $postprocessors[] = new $postprocessor($this->builder);
135 1
                $this->builder->getLogger()->debug(\sprintf('Output post processor "%s" loaded', $name));
136 1
            } catch (\Exception $e) {
137 1
                $this->builder->getLogger()->error(\sprintf('Unable to load output post processor "%s": %s', $name, $e->getMessage()));
138
            }
139
        }
140
141
        // some caches to avoid multiple calls
142 1
        $cacheLocale = $cacheSite = $cacheConfig = [];
143
144
        /** @var Page $page */
145 1
        foreach ($pages as $page) {
146 1
            $count++;
147 1
            $rendered = [];
148
149
            // l10n
150 1
            $language = $page->getVariable('language', $this->config->getLanguageDefault());
151 1
            if (!isset($cacheLocale[$language])) {
152 1
                $cacheLocale[$language] = $this->config->getLanguageProperty('locale', $language);
153
            }
154 1
            $this->builder->getRenderer()->setLocale($cacheLocale[$language]);
155
156
            // global site variables
157 1
            if (!isset($cacheSite[$language])) {
158 1
                $cacheSite[$language] = new Site($this->builder, $language);
159
            }
160 1
            $this->builder->getRenderer()->addGlobal('site', $cacheSite[$language]);
161
162
            // global config raw variables
163 1
            if (!isset($cacheConfig[$language])) {
164 1
                $cacheConfig[$language] = new Config($this->builder, $language);
165
            }
166 1
            $this->builder->getRenderer()->addGlobal('config', $cacheConfig[$language]);
167
168
            // excluded format(s)?
169 1
            $formats = (array) $page->getVariable('output');
170 1
            foreach ($formats as $key => $format) {
171 1
                if ($exclude = $this->config->getOutputFormatProperty($format, 'exclude')) {
172
                    // ie:
173
                    //   formats:
174
                    //     atom:
175
                    //       [...]
176
                    //       exclude: [paginated]
177 1
                    if (!\is_array($exclude)) {
178
                        $exclude = [$exclude];
179
                    }
180 1
                    foreach ($exclude as $variable) {
181 1
                        if ($page->hasVariable($variable)) {
182 1
                            unset($formats[$key]);
183
                        }
184
                    }
185
                }
186
            }
187
188
            // specific output format from subset
189 1
            if (!empty($this->subset['output'])) {
190
                $currentFormats = $formats;
191
                $formats = [];
192
                if (\in_array((string) $this->subset['output'], $currentFormats)) {
193
                    $formats = [(string) $this->subset['output']];
194
                }
195
            }
196
197
            // renders each output format
198 1
            foreach ($formats as $format) {
199
                // search for the template
200 1
                $layout = Layout::finder($page, $format, $this->config);
201
                // renders with Twig
202
                try {
203 1
                    $deprecations = [];
204 1
                    set_error_handler(function ($type, $msg) use (&$deprecations) {
205 1
                        if (E_USER_DEPRECATED === $type) {
206 1
                            $deprecations[] = $msg;
207
                        }
208 1
                    });
209 1
                    $output = $this->builder->getRenderer()->render($layout['file'], ['page' => $page]);
210 1
                    foreach ($deprecations as $value) {
211 1
                        $this->builder->getLogger()->warning($value);
212
                    }
213 1
                    foreach ($postprocessors as $postprocessor) {
214 1
                        $output = $postprocessor->process($page, $output, $format);
215
                    }
216 1
                    $rendered[$format] = [
217 1
                        'output'   => $output,
218 1
                        'template' => [
219 1
                            'scope' => $layout['scope'],
220 1
                            'file'  => $layout['file'],
221 1
                        ],
222 1
                    ];
223 1
                    $page->addRendered($rendered);
224
                } catch (\Twig\Error\Error $e) {
225
                    throw new RuntimeException(
226
                        \sprintf(
227
                            'Can\'t render template "%s" for page "%s".',
228
                            $e->getSourceContext()->getName(),
229
                            $page->getFileName() ?? $page->getId()
230
                        ),
231
                        previous: $e,
232
                        file: $e->getSourceContext()->getPath(),
233
                        line: $e->getTemplateLine(),
234
                    );
235
                } catch (\Exception $e) {
236
                    throw new RuntimeException($e->getMessage(), previous: $e);
237
                }
238
            }
239 1
            $this->builder->getPages()->replace($page->getId(), $page);
240
241 1
            $templates = array_column($rendered, 'template');
242 1
            $message = \sprintf(
243 1
                'Page "%s" rendered with [%s]',
244 1
                $page->getId() ?: 'index',
245 1
                Util\Str::combineArrayToString($templates, 'scope', 'file')
246 1
            );
247 1
            $this->builder->getLogger()->info($message, ['progress' => [$count, $total]]);
248
        }
249
        // profiler
250 1
        if ($this->builder->isDebug()) {
251
            try {
252
                // HTML
253 1
                $htmlDumper = new \Twig\Profiler\Dumper\HtmlDumper();
254 1
                $profileHtmlFile = Util::joinFile($this->config->getDestinationDir(), self::TMP_DIR, 'twig_profile.html');
255 1
                Util\File::getFS()->dumpFile($profileHtmlFile, $htmlDumper->dump($this->builder->getRenderer()->getDebugProfile()));
256
                // TXT
257 1
                $textDumper = new \Twig\Profiler\Dumper\TextDumper();
258 1
                $profileTextFile = Util::joinFile($this->config->getDestinationDir(), self::TMP_DIR, 'twig_profile.txt');
259 1
                Util\File::getFS()->dumpFile($profileTextFile, $textDumper->dump($this->builder->getRenderer()->getDebugProfile()));
260
                // log
261 1
                $this->builder->getLogger()->debug(\sprintf('Twig profile dumped in "%s"', Util::joinFile($this->config->getDestinationDir(), self::TMP_DIR)));
262
            } catch (\Symfony\Component\Filesystem\Exception\IOException $e) {
263
                throw new RuntimeException($e->getMessage());
264
            }
265
        }
266
    }
267
268
    /**
269
     * Returns an array of layouts directories.
270
     */
271 1
    protected function getAllLayoutsPaths(): array
272
    {
273 1
        $paths = [];
274
275
        // layouts/
276 1
        if (is_dir($this->config->getLayoutsPath())) {
277 1
            $paths[] = $this->config->getLayoutsPath();
278
        }
279
        // <theme>/layouts/
280 1
        if ($this->config->hasTheme()) {
281 1
            foreach ($this->config->getTheme() ?? [] as $theme) {
282 1
                $paths[] = $this->config->getThemeDirPath($theme);
283
            }
284
        }
285
        // resources/layouts/
286 1
        if (is_dir($this->config->getLayoutsInternalPath())) {
287 1
            $paths[] = $this->config->getLayoutsInternalPath();
288
        }
289
290 1
        return $paths;
291
    }
292
293
    /**
294
     * Adds global variables.
295
     */
296 1
    protected function addGlobals()
297
    {
298 1
        $this->builder->getRenderer()->addGlobal('cecil', [
299 1
            'url'       => \sprintf('https://cecil.app/#%s', Builder::getVersion()),
300 1
            'version'   => Builder::getVersion(),
301 1
            'poweredby' => \sprintf('Cecil v%s', Builder::getVersion()),
302 1
        ]);
303
    }
304
305
    /**
306
     * Get available output formats.
307
     *
308
     * @throws RuntimeException
309
     */
310 1
    protected function getOutputFormats(Page $page): array
311
    {
312
        // Get page output format(s) if defined.
313
        // ie:
314
        // ```yaml
315
        // output: txt
316
        // ```
317 1
        if ($page->getVariable('output')) {
318 1
            $formats = $page->getVariable('output');
319 1
            if (!\is_array($formats)) {
320 1
                $formats = [$formats];
321
            }
322
323 1
            return $formats;
324
        }
325
326
        // Get available output formats for the page type.
327
        // ie:
328
        // ```yaml
329
        // page: [html, json]
330
        // ```
331 1
        $formats = $this->config->get('output.pagetypeformats.' . $page->getType());
332 1
        if (empty($formats)) {
333
            throw new RuntimeException('Configuration key "pagetypeformats" can\'t be empty.');
334
        }
335 1
        if (!\is_array($formats)) {
336
            $formats = [$formats];
337
        }
338
339 1
        return array_unique($formats);
340
    }
341
342
    /**
343
     * Get alternates.
344
     */
345 1
    protected function getAlternates(array $formats): array
346
    {
347 1
        $alternates = [];
348
349 1
        if (\count($formats) > 1 || \in_array('html', $formats)) {
350 1
            foreach ($formats as $format) {
351 1
                $format == 'html' ? $rel = 'canonical' : $rel = 'alternate';
352 1
                $alternates[] = [
353 1
                    'rel'    => $rel,
354 1
                    'type'   => $this->config->getOutputFormatProperty($format, 'mediatype'),
355 1
                    'title'  => strtoupper($format),
356 1
                    'format' => $format,
357 1
                ];
358
            }
359
        }
360
361 1
        return $alternates;
362
    }
363
364
    /**
365
     * Returns the collection of translated pages for a given page.
366
     */
367 1
    protected function getTranslations(Page $refPage): Collection
368
    {
369 1
        $pages = $this->builder->getPages()->filter(function (Page $page) use ($refPage) {
370 1
            return $page->getId() !== $refPage->getId()
371 1
                && $page->getVariable('langref') == $refPage->getVariable('langref')
372 1
                && $page->getType() == $refPage->getType()
373 1
                && !empty($page->getVariable('published'))
374 1
                && !$page->getVariable('paginated');
375 1
        });
376
377 1
        return $pages;
378
    }
379
}
380